diff --git a/.gitea/scripts/publish_modrinth.mjs b/.gitea/scripts/publish_modrinth.mjs new file mode 100644 index 0000000..943d847 --- /dev/null +++ b/.gitea/scripts/publish_modrinth.mjs @@ -0,0 +1,164 @@ +#!/usr/bin/env node +// Publish the jars attached to a Gitea release to Modrinth. +// +// Creates one Modrinth version per jar (saturn-client-+.jar), for +// that Minecraft version, with Fabric API as a required dependency and the +// Gitea release notes as the changelog. Versions that already exist on +// Modrinth are skipped, so the script is safe to re-run. +// +// Runs on plain Node (>= 18: fetch, FormData, Blob) with no dependencies, since +// every Gitea Actions runner has Node to run JavaScript actions. +// +// Environment: +// TAG release tag, e.g. 0.1.1-beta +// SERVER_URL public Gitea URL, e.g. https://git.selimaj.dev +// REPO owner/name, e.g. saturnclientmc/saturnclient +// MODRINTH_TOKEN Modrinth PAT with the "Create versions" scope +// DRY_RUN "true" to check everything and print what would be +// published without uploading (no token needed) + +const MODRINTH_API = "https://api.modrinth.com/v2"; +const MODRINTH_PROJECT_ID = "i6JDSY9x"; // saturnclient +const FABRIC_API_PROJECT_ID = "P7dR8mSH"; +const USER_AGENT = + "saturnclientmc/saturnclient release workflow (https://git.selimaj.dev/saturnclientmc/saturnclient)"; + +const JAR_NAME = /^saturn-client-(?.+)\+(?\d+\.\d+(?:\.\d+)?)\.jar$/; + +function fail(message) { + console.error(message); + process.exit(1); +} + +async function http(url, options = {}) { + const res = await fetch(url, { + ...options, + headers: { "User-Agent": USER_AGENT, ...options.headers }, + }); + if (!res.ok) { + const error = new Error(`${options.method ?? "GET"} ${url} returned ${res.status}: ${await res.text()}`); + error.status = res.status; + throw error; + } + return res; +} + +async function getJson(url) { + return (await http(url)).json(); +} + +function versionType(tag) { + const t = tag.toLowerCase(); + if (t.includes("alpha")) return "alpha"; + if (t.includes("beta") || t.includes("rc") || t.includes("pre")) return "beta"; + return "release"; +} + +function compareMc(a, b) { + const pa = a.split(".").map(Number); + const pb = b.split(".").map(Number); + for (let i = 0; i < Math.max(pa.length, pb.length); i++) { + const diff = (pa[i] ?? 0) - (pb[i] ?? 0); + if (diff !== 0) return diff; + } + return 0; +} + +async function main() { + const { TAG: tag, SERVER_URL, REPO: repo } = process.env; + const serverUrl = (SERVER_URL ?? "").replace(/\/+$/, ""); + const dryRun = (process.env.DRY_RUN ?? "").toLowerCase() === "true"; + const token = process.env.MODRINTH_TOKEN ?? ""; + + if (!tag || !serverUrl || !repo) fail("TAG, SERVER_URL and REPO must be set."); + if (!token && !dryRun) { + fail("MODRINTH_TOKEN is not set. Add it as a repository secret (Settings → Actions → Secrets)."); + } + + let release; + try { + release = await getJson(`${serverUrl}/api/v1/repos/${repo}/releases/tags/${encodeURIComponent(tag)}`); + } catch (e) { + if (e.status === 404) fail(`No Gitea release with tag ${tag}.`); + throw e; + } + const changelog = release.body ?? ""; + + const jars = (release.assets ?? []) + .map((asset) => ({ name: asset.name, match: JAR_NAME.exec(asset.name) })) + .filter(({ match }) => match) + .map(({ name, match }) => ({ name, version: match.groups.version, mc: match.groups.mc })) + .sort((a, b) => compareMc(a.mc, b.mc)); + + if (jars.length === 0) fail(`Release ${tag} has no saturn-client-*.jar assets. Run the Release workflow first.`); + + for (const jar of jars) { + if (jar.version !== tag.replace(/^v/, "")) fail(`${jar.name} is version ${jar.version}, but the release tag is ${tag}.`); + } + + const knownGameVersions = new Set((await getJson(`${MODRINTH_API}/tag/game_version`)).map((g) => g.version)); + const unknown = [...new Set(jars.map((j) => j.mc))].filter((mc) => !knownGameVersions.has(mc)); + if (unknown.length > 0) fail(`Modrinth doesn't know these Minecraft versions: ${unknown.join(", ")}`); + + const existing = new Set( + (await getJson(`${MODRINTH_API}/project/${MODRINTH_PROJECT_ID}/version`)).map((v) => v.version_number), + ); + const kind = versionType(tag); + const failed = []; + + for (const { name, version, mc } of jars) { + const versionNumber = `${version}+${mc}`; + + if (existing.has(versionNumber)) { + console.log(`skip ${versionNumber}: already on Modrinth`); + continue; + } + + // Gitea's own download URLs use its internal ROOT_URL, so build a public one. + const downloadUrl = `${serverUrl}/${repo}/releases/download/${encodeURIComponent(tag)}/${encodeURIComponent(name)}`; + + const data = { + project_id: MODRINTH_PROJECT_ID, + name: `Saturn Client ${versionNumber}`, + version_number: versionNumber, + changelog, + dependencies: [{ project_id: FABRIC_API_PROJECT_ID, dependency_type: "required" }], + game_versions: [mc], + version_type: kind, + loaders: ["fabric"], + featured: false, + status: "listed", + file_parts: ["file"], + primary_file: "file", + }; + + if (dryRun) { + const probe = await http(downloadUrl, { headers: { Range: "bytes=0-0" } }); + await probe.arrayBuffer(); + console.log(`would publish ${versionNumber} (${kind}, Minecraft ${mc}) from ${downloadUrl} [reachable]`); + continue; + } + + try { + const jarBytes = await (await http(downloadUrl)).arrayBuffer(); + + const form = new FormData(); + form.append("data", JSON.stringify(data)); + form.append("file", new Blob([jarBytes], { type: "application/java-archive" }), name); + + await http(`${MODRINTH_API}/version`, { + method: "POST", + headers: { Authorization: token }, + body: form, + }); + console.log(`publish ${versionNumber}: done (${(jarBytes.byteLength / 1_000_000).toFixed(1)} MB)`); + } catch (e) { + console.error(`publish ${versionNumber}: FAILED ${e.message}`); + failed.push(versionNumber); + } + } + + if (failed.length > 0) fail(`Failed to publish: ${failed.join(", ")}`); +} + +main().catch((e) => fail(e.stack ?? String(e))); diff --git a/.gitea/scripts/publish_modrinth.py b/.gitea/scripts/publish_modrinth.py deleted file mode 100644 index 5487259..0000000 --- a/.gitea/scripts/publish_modrinth.py +++ /dev/null @@ -1,167 +0,0 @@ -#!/usr/bin/env python3 -"""Publish the jars attached to a Gitea release to Modrinth. - -Creates one Modrinth version per jar (saturn-client-+.jar), for -that Minecraft version, with Fabric API as a required dependency and the Gitea -release notes as the changelog. Versions that already exist on Modrinth are -skipped, so the script is safe to re-run. - -Environment: - TAG release tag, e.g. 0.1.1-beta - SERVER_URL public Gitea URL, e.g. https://git.selimaj.dev - REPO owner/name, e.g. saturnclientmc/saturnclient - MODRINTH_TOKEN Modrinth PAT with the "Create versions" scope - DRY_RUN "true" to check everything and print what would be - published without uploading (no token needed) -""" - -import json -import os -import re -import sys -import urllib.error -import urllib.parse -import urllib.request -import uuid - -MODRINTH_API = "https://api.modrinth.com/v2" -MODRINTH_PROJECT_ID = "i6JDSY9x" # saturnclient -FABRIC_API_PROJECT_ID = "P7dR8mSH" -USER_AGENT = "saturnclientmc/saturnclient release workflow (https://git.selimaj.dev/saturnclientmc/saturnclient)" - -JAR_NAME = re.compile(r"^saturn-client-(?P.+)\+(?P\d+\.\d+(?:\.\d+)?)\.jar$") - - -def request(url, data=None, headers=None, method=None): - req = urllib.request.Request(url, data=data, method=method) - req.add_header("User-Agent", USER_AGENT) - for key, value in (headers or {}).items(): - req.add_header(key, value) - with urllib.request.urlopen(req, timeout=300) as res: - return res.read() - - -def get_json(url, headers=None): - return json.loads(request(url, headers=headers)) - - -def version_type(tag): - tag = tag.lower() - if "alpha" in tag: - return "alpha" - if "beta" in tag or "rc" in tag or "pre" in tag: - return "beta" - return "release" - - -def mc_sort_key(mc): - return tuple(int(part) for part in mc.split(".")) - - -def multipart(fields, file_field, file_name, file_bytes): - boundary = uuid.uuid4().hex - parts = [] - for name, value in fields.items(): - parts.append( - f'--{boundary}\r\nContent-Disposition: form-data; name="{name}"\r\n' - f"Content-Type: application/json\r\n\r\n{value}\r\n".encode() - ) - parts.append( - f'--{boundary}\r\nContent-Disposition: form-data; name="{file_field}"; filename="{file_name}"\r\n' - f"Content-Type: application/java-archive\r\n\r\n".encode() - ) - parts.append(file_bytes) - parts.append(f"\r\n--{boundary}--\r\n".encode()) - return b"".join(parts), f"multipart/form-data; boundary={boundary}" - - -def main(): - tag = os.environ["TAG"] - server_url = os.environ["SERVER_URL"].rstrip("/") - repo = os.environ["REPO"] - dry_run = os.environ.get("DRY_RUN", "").lower() == "true" - token = os.environ.get("MODRINTH_TOKEN", "") - - if not token and not dry_run: - sys.exit("MODRINTH_TOKEN is not set. Add it as a repository secret (Settings → Actions → Secrets).") - - try: - release = get_json(f"{server_url}/api/v1/repos/{repo}/releases/tags/{urllib.parse.quote(tag)}") - except urllib.error.HTTPError as e: - if e.code == 404: - sys.exit(f"No Gitea release with tag {tag}.") - raise - changelog = release.get("body") or "" - - jars = [] - for asset in release.get("assets", []): - match = JAR_NAME.match(asset["name"]) - if match: - jars.append((match["mc"], match["version"], asset["name"])) - - if not jars: - sys.exit(f"Release {tag} has no saturn-client-*.jar assets. Run the Release workflow first.") - - for mc, version, name in jars: - if version != tag.removeprefix("v"): - sys.exit(f"{name} is version {version}, but the release tag is {tag}.") - - known_game_versions = {g["version"] for g in get_json(f"{MODRINTH_API}/tag/game_version")} - unknown = sorted({mc for mc, _, _ in jars} - known_game_versions) - if unknown: - sys.exit(f"Modrinth doesn't know these Minecraft versions: {', '.join(unknown)}") - - existing = {v["version_number"] for v in get_json(f"{MODRINTH_API}/project/{MODRINTH_PROJECT_ID}/version")} - kind = version_type(tag) - failed = [] - - for mc, version, name in sorted(jars, key=lambda jar: mc_sort_key(jar[0])): - version_number = f"{version}+{mc}" - - if version_number in existing: - print(f"skip {version_number}: already on Modrinth") - continue - - # Gitea's own download URLs use its internal ROOT_URL, so build a public one. - download_url = f"{server_url}/{repo}/releases/download/{urllib.parse.quote(tag)}/{urllib.parse.quote(name)}" - - data = { - "project_id": MODRINTH_PROJECT_ID, - "name": f"Saturn Client {version_number}", - "version_number": version_number, - "changelog": changelog, - "dependencies": [{"project_id": FABRIC_API_PROJECT_ID, "dependency_type": "required"}], - "game_versions": [mc], - "version_type": kind, - "loaders": ["fabric"], - "featured": False, - "status": "listed", - "file_parts": ["file"], - "primary_file": "file", - } - - if dry_run: - size = request(download_url, headers={"Range": "bytes=0-0"}) - print(f"would publish {version_number} ({kind}, Minecraft {mc}) from {download_url} [reachable: {len(size) > 0}]") - continue - - try: - jar_bytes = request(download_url) - body, content_type = multipart({"data": json.dumps(data)}, "file", name, jar_bytes) - request( - f"{MODRINTH_API}/version", - data=body, - headers={"Authorization": token, "Content-Type": content_type}, - method="POST", - ) - print(f"publish {version_number}: done ({len(jar_bytes) / 1_000_000:.1f} MB)") - except urllib.error.HTTPError as e: - print(f"publish {version_number}: FAILED {e.code} {e.read().decode(errors='replace')}", file=sys.stderr) - failed.append(version_number) - - if failed: - sys.exit(f"Failed to publish: {', '.join(failed)}") - - -if __name__ == "__main__": - main() diff --git a/.gitea/workflows/modrinth.yml b/.gitea/workflows/modrinth.yml index c71c999..e18d737 100644 --- a/.gitea/workflows/modrinth.yml +++ b/.gitea/workflows/modrinth.yml @@ -25,11 +25,6 @@ jobs: - name: Checkout uses: actions/checkout@v4 - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: "3.12" - - name: Publish to Modrinth env: TAG: ${{ inputs.tag }} @@ -37,4 +32,5 @@ jobs: SERVER_URL: ${{ github.server_url }} REPO: ${{ github.repository }} MODRINTH_TOKEN: ${{ secrets.MODRINTH_TOKEN }} - run: python .gitea/scripts/publish_modrinth.py + # Node is always available on Gitea runners (it runs JavaScript actions). + run: node .gitea/scripts/publish_modrinth.mjs