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). 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 It previews a release unless `--publish` is explicitly supplied. The current
@@ -8,6 +8,12 @@ is enabled for it.
## One-time token setup ## 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) Create a [Modrinth personal access token](https://docs.modrinth.com/api/#authentication)
in account settings with `VERSION_CREATE` (upload) and `USER_READ` (read-only in account settings with `VERSION_CREATE` (upload) and `USER_READ` (read-only
credential check) scopes. Create a credential check) scopes. Create a
@@ -21,6 +27,7 @@ corresponding token into the hidden prompt:
```sh ```sh
python3 scripts/publish_mod_sites.py store-token modrinth 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 curseforge
python3 scripts/publish_mod_sites.py store-token gitea
``` ```
The tokens are stored in the desktop Secret Service keyring under The tokens are stored in the desktop Secret Service keyring under
@@ -34,6 +41,7 @@ Verify the credentials without uploading anything:
```sh ```sh
python3 scripts/publish_mod_sites.py check-token modrinth 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 curseforge
python3 scripts/publish_mod_sites.py check-token gitea
``` ```
## Release workflow ## 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 existing version number; CurseForge may not, so check its file list before any
retry after a partial failure. CurseForge approval may take additional time. 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 [`POST /version`](https://docs.modrinth.com/api/operations/createversion/) and
CurseForge's documented CurseForge's documented
[`POST /api/projects/{projectId}/upload-file`](https://support.curseforge.com/support/solutions/articles/9000197321) [`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 #!/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. Preview is the default. Tokens live in the Secret Service keyring, never in git.
""" """
@@ -19,8 +19,9 @@ import urllib.request
MODRINTH_PROJECT = "GH2xfZJ8" MODRINTH_PROJECT = "GH2xfZJ8"
CURSEFORGE_PROJECT = "1766326" 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)" USER_AGENT = "RivencraftReleaseTool/1.0 (https://git.paddedcell.org/dnorberg/rivencraft)"
SITES = ("modrinth", "curseforge") SITES = ("gitea", "modrinth", "curseforge")
def token_for(site: str) -> str: def token_for(site: str) -> str:
@@ -59,7 +60,13 @@ def store_token(site: str) -> None:
def check_token(site: str) -> None: def check_token(site: str) -> None:
token = token_for(site) 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: try:
account = request_json("https://api.modrinth.com/v2/user", token=token) account = request_json("https://api.modrinth.com/v2/user", token=token)
except RuntimeError as error: 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", def request_json(url: str, *, token: str | None = None, token_header: str = "Authorization",
payload: bytes | None = None, content_type: str | None = None, 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"} headers = {"User-Agent": USER_AGENT, "Accept": "application/json"}
if token: if token:
headers[token_header] = 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: with urllib.request.urlopen(request, timeout=120) as response:
return json.load(response) return json.load(response)
except urllib.error.HTTPError as error: 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. # Error bodies can contain server details, but never echo tokens.
detail = error.read(600).decode("utf-8", "replace") detail = error.read(600).decode("utf-8", "replace")
raise RuntimeError(f"{url}: HTTP {error.code}: {detail}") from None 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) boundary = "rivencraft-" + secrets.token_hex(16)
chunks = [] chunks = []
for name, value in fields.items(): 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('"', "") filename = jar.name.replace('"', "")
mime = mimetypes.guess_type(filename)[0] or "application/java-archive" mime = mimetypes.guess_type(filename)[0] or "application/java-archive"
chunks.extend(( 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(), jar.read_bytes(), b"\r\n", f"--{boundary}--\r\n".encode(),
)) ))
return b"".join(chunks), f"multipart/form-data; boundary={boundary}" 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)") 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: def main() -> None:
parser = argparse.ArgumentParser(description=__doc__) parser = argparse.ArgumentParser(description=__doc__)
sub = parser.add_subparsers(dest="command", required=True) sub = parser.add_subparsers(dest="command", required=True)
@@ -186,7 +242,7 @@ def main() -> None:
publish.add_argument("--minecraft", required=True) publish.add_argument("--minecraft", required=True)
publish.add_argument("--java", required=True) publish.add_argument("--java", required=True)
publish.add_argument("--channel", choices=("alpha", "beta", "release"), 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") publish.add_argument("--publish", action="store_true", help="Actually upload; omitted by default")
args = parser.parse_args() args = parser.parse_args()
if args.command == "store-token": if args.command == "store-token":
@@ -204,13 +260,15 @@ def main() -> None:
if args.jar.name != f"rivencraft-{args.minecraft}-{args.version}.jar": if args.jar.name != f"rivencraft-{args.minecraft}-{args.version}.jar":
raise RuntimeError("JAR filename does not match Minecraft and release versions") raise RuntimeError("JAR filename does not match Minecraft and release versions")
modrinth, curseforge = release_metadata(args) 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"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"Release: {args.version} | {args.channel} | Minecraft {args.minecraft} | NeoForge | Java {args.java}")
print(f"Destinations: {', '.join(sites)}") print(f"Destinations: {', '.join(sites)}")
if not args.publish: if not args.publish:
print("PREVIEW ONLY: add --publish to upload") print("PREVIEW ONLY: add --publish to upload")
return return
if "gitea" in sites:
publish_gitea(args)
if "modrinth" in sites: if "modrinth" in sites:
publish_modrinth(modrinth, args.jar) publish_modrinth(modrinth, args.jar)
if "curseforge" in sites: if "curseforge" in sites: