Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
91b53daac9 | ||
|
|
5bcdc72b83 | ||
|
|
3f0670ea9d | ||
|
|
c54b5ae6d7 |
@@ -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)));
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
name: Publish to Modrinth
|
||||||
|
|
||||||
|
# Publishes the jars already attached to a Gitea release (built by the Release
|
||||||
|
# workflow) to Modrinth, one version per Minecraft version. Run it manually
|
||||||
|
# after smoke-testing the release. It doesn't rebuild anything, and versions
|
||||||
|
# already on Modrinth are skipped, so re-running is safe.
|
||||||
|
#
|
||||||
|
# Needs a MODRINTH_TOKEN repository secret: a Modrinth PAT with the
|
||||||
|
# "Create versions" scope.
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
tag:
|
||||||
|
description: Tag of the Gitea release to publish
|
||||||
|
required: true
|
||||||
|
dry_run:
|
||||||
|
description: Only check and print what would be published
|
||||||
|
type: boolean
|
||||||
|
default: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
publish:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Publish to Modrinth
|
||||||
|
env:
|
||||||
|
TAG: ${{ inputs.tag }}
|
||||||
|
DRY_RUN: ${{ inputs.dry_run }}
|
||||||
|
SERVER_URL: ${{ github.server_url }}
|
||||||
|
REPO: ${{ github.repository }}
|
||||||
|
MODRINTH_TOKEN: ${{ secrets.MODRINTH_TOKEN }}
|
||||||
|
# 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