Publish to Modrinth with Node instead of Python
actions/setup-python can only install Python on GitHub's Ubuntu images,
so it fails on this runner's job image ("version 3.12 ... was not found
for this operating system"). Every Gitea runner has Node, since it runs
JavaScript actions, so port the publisher to a dependency-free Node
script (fetch/FormData/Blob) and drop the setup step. Behaviour is
unchanged.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -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-<version>+<mc>.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-(?<version>.+)\+(?<mc>\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)));
|
||||||
@@ -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-<version>+<mc>.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<version>.+)\+(?P<mc>\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()
|
|
||||||
@@ -25,11 +25,6 @@ jobs:
|
|||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Set up Python
|
|
||||||
uses: actions/setup-python@v5
|
|
||||||
with:
|
|
||||||
python-version: "3.12"
|
|
||||||
|
|
||||||
- name: Publish to Modrinth
|
- name: Publish to Modrinth
|
||||||
env:
|
env:
|
||||||
TAG: ${{ inputs.tag }}
|
TAG: ${{ inputs.tag }}
|
||||||
@@ -37,4 +32,5 @@ jobs:
|
|||||||
SERVER_URL: ${{ github.server_url }}
|
SERVER_URL: ${{ github.server_url }}
|
||||||
REPO: ${{ github.repository }}
|
REPO: ${{ github.repository }}
|
||||||
MODRINTH_TOKEN: ${{ secrets.MODRINTH_TOKEN }}
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user