#!/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)));