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).
|
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)
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Reference in new issue
Block a user