Merge pull request 'Publish to Modrinth with Node instead of Python' (#16) from modrinth-node into master
Reviewed-on: #16
This commit was merged in pull request #16.
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
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user