#!/usr/bin/env python3 import glob import json import os import pathlib import subprocess import sys import tempfile import urllib.error import urllib.parse import urllib.request ROOT = pathlib.Path(__file__).resolve().parent.parent GRADLE_PROPERTIES = ROOT / "gradle.properties" def fail(message: str) -> None: print(f"ERROR: {message}", file=sys.stderr) raise SystemExit(1) def read_gradle_properties() -> dict[str, str]: values: dict[str, str] = {} for line in GRADLE_PROPERTIES.read_text(encoding="utf-8").splitlines(): stripped = line.strip() if not stripped or stripped.startswith("#") or "=" not in stripped: continue key, value = stripped.split("=", 1) values[key.strip()] = value.strip() return values def load_release_event() -> dict: event_path = os.environ.get("GITHUB_EVENT_PATH") if not event_path: fail("GITHUB_EVENT_PATH is not set. This script must run inside a release workflow.") with open(event_path, "r", encoding="utf-8") as handle: payload = json.load(handle) if "release" not in payload: fail("Workflow event payload does not contain release data.") return payload def env_required(name: str) -> str: value = os.environ.get(name, "").strip() if not value: fail(f"Missing required environment value: {name}") return value def env_optional(name: str, default: str = "") -> str: return os.environ.get(name, default).strip() def first_non_empty(*values: str) -> str: for value in values: if value: return value return "" def split_csv(value: str) -> list[str]: return [part.strip() for part in value.split(",") if part.strip()] def derive_release_channel(tag_name: str, release_name: str, prerelease: bool) -> str: text = " ".join([tag_name, release_name]).lower() if "alpha" in text: return "alpha" if prerelease or "beta" in text or "rc" in text or "pre" in text: return "beta" return "release" def select_artifact(mod_id: str, version_hint: str) -> pathlib.Path: pattern = env_optional("RELEASE_ARTIFACT_GLOB", "build/libs/*.jar") candidates = [ pathlib.Path(path) for path in glob.glob(str(ROOT / pattern)) if pathlib.Path(path).is_file() ] filtered = [ path for path in candidates if not any( marker in path.name for marker in ("-sources", "-javadoc", "-api", "-dev", "-slim") ) ] if not filtered: fail(f"No release jar matched {pattern}") version_name = version_hint[1:] if version_hint.startswith("v") else version_hint exact_name = f"{mod_id}-{version_name}.jar" for path in filtered: if path.name == exact_name: return path filtered.sort(key=lambda path: (path.name.count("-"), len(path.name), path.name)) return filtered[0] def http_json(method: str, url: str, headers: dict[str, str]) -> tuple[int, object]: request = urllib.request.Request(url, method=method) for key, value in headers.items(): request.add_header(key, value) try: with urllib.request.urlopen(request) as response: body = response.read().decode("utf-8") return response.status, json.loads(body) if body else None except urllib.error.HTTPError as error: body = error.read().decode("utf-8", errors="replace") return error.code, json.loads(body) if body else None def curl_request(arguments: list[str], expected_codes: set[int]) -> str: with tempfile.NamedTemporaryFile(delete=False) as output_file: output_path = output_file.name try: command = [ "curl", "--silent", "--show-error", "--location", "--output", output_path, "--write-out", "%{http_code}", *arguments, ] result = subprocess.run(command, capture_output=True, text=True, check=False) if result.returncode != 0: fail(result.stderr.strip() or "curl failed") status_code = int(result.stdout.strip()) body = pathlib.Path(output_path).read_text(encoding="utf-8", errors="replace") if status_code not in expected_codes: fail(f"HTTP {status_code} from remote API:\n{body}") return body finally: pathlib.Path(output_path).unlink(missing_ok=True) def gitea_headers(token: str) -> dict[str, str]: return { "Accept": "application/json", "Authorization": f"token {token}", } def attach_to_gitea_release( server_url: str, owner: str, repo: str, release_id: int, artifact: pathlib.Path, token: str, ) -> None: asset_name = artifact.name asset_base = ( f"{server_url.rstrip('/')}/api/v1/repos/" f"{urllib.parse.quote(owner)}/{urllib.parse.quote(repo)}/releases/{release_id}/assets" ) headers = gitea_headers(token) status, payload = http_json("GET", asset_base, headers) if status == 200 and isinstance(payload, list): for asset in payload: if asset.get("name") == asset_name: delete_url = f"{asset_base}/{asset['id']}" delete_status, _ = http_json("DELETE", delete_url, headers) if delete_status not in (204, 404): fail(f"Failed to delete existing Gitea release asset {asset_name}") elif status != 404: fail(f"Could not list Gitea release assets (HTTP {status})") upload_url = f"{asset_base}?name={urllib.parse.quote(asset_name)}" print(f"Uploading {asset_name} to Gitea release #{release_id}") curl_request( [ "--request", "POST", "--header", f"Authorization: token {token}", "--header", "Accept: application/json", "--form", f"attachment=@{artifact}", upload_url, ], {201}, ) def upload_to_modrinth( artifact: pathlib.Path, token: str, project_id: str, version_number: str, version_name: str, changelog: str, release_channel: str, game_versions: list[str], loaders: list[str], ) -> None: payload = { "project_id": project_id, "version_number": version_number, "version_title": version_name, "version_type": release_channel, "status": "listed", "featured": False, "loaders": loaders, "game_versions": game_versions, "changelog": changelog, "file_parts": ["file"], "primary_file": "file", } print(f"Publishing {artifact.name} to Modrinth project {project_id}") curl_request( [ "--request", "POST", "--header", f"Authorization: {token}", "--header", "User-Agent: bagtabs-release-pipeline/1.0 (self-hosted Gitea)", "--form", f"data={json.dumps(payload)}", "--form", f"file=@{artifact}", "https://api.modrinth.com/v2/version", ], {200, 201}, ) def resolve_curseforge_game_version_ids( token: str, game_version_names: list[str], explicit_ids: list[str], api_base: str, ) -> list[int]: if explicit_ids: return [int(value) for value in explicit_ids] if not game_version_names: fail("Set CURSEFORGE_GAME_VERSION_IDS or CURSEFORGE_GAME_VERSION_NAMES for CurseForge publishing.") url = f"{api_base.rstrip('/')}/api/game/versions" request = urllib.request.Request(url, method="GET") request.add_header("X-Api-Token", token) try: with urllib.request.urlopen(request) as response: versions = json.loads(response.read().decode("utf-8")) except urllib.error.HTTPError as error: body = error.read().decode("utf-8", errors="replace") fail(f"Could not read CurseForge game versions (HTTP {error.code}):\n{body}") matches: list[int] = [] missing: list[str] = [] for name in game_version_names: matched = next((entry for entry in versions if entry.get("name") == name), None) if not matched: missing.append(name) else: matches.append(int(matched["id"])) if missing: fail( "Could not resolve CurseForge game version names: " + ", ".join(missing) + ". Set CURSEFORGE_GAME_VERSION_IDS to bypass lookup." ) return matches def upload_to_curseforge( artifact: pathlib.Path, token: str, project_id: str, display_name: str, changelog: str, release_channel: str, game_version_ids: list[int], api_base: str, ) -> None: metadata = { "changelog": changelog, "changelogType": "markdown", "displayName": display_name, "gameVersions": game_version_ids, "releaseType": release_channel, } url = f"{api_base.rstrip('/')}/api/projects/{project_id}/upload-file" print(f"Publishing {artifact.name} to CurseForge project {project_id}") curl_request( [ "--request", "POST", "--header", f"X-Api-Token: {token}", "--form", f"metadata={json.dumps(metadata)}", "--form", f"file=@{artifact}", url, ], {200, 201}, ) def main() -> None: gradle_properties = read_gradle_properties() event = load_release_event() release = event["release"] repository = event.get("repository", {}) repository_full_name = repository.get("full_name", "") repository_parts = repository_full_name.split("/", 1) if "/" in repository_full_name else ["", ""] mod_id = gradle_properties["mod_id"] mod_name = gradle_properties["mod_name"] tag_name = release.get("tag_name") if not tag_name: fail("Release tag name missing from event payload.") version_number = tag_name[1:] if tag_name.startswith("v") else tag_name version_name = release.get("name") or f"{mod_name} {version_number}" changelog = release.get("body") or f"Release {version_name}" release_channel = derive_release_channel(tag_name, version_name, bool(release.get("prerelease"))) artifact = select_artifact(mod_id, tag_name) print(f"Selected artifact: {artifact}") server_url = first_non_empty(env_optional("GITEA_SERVER_URL"), env_optional("GITHUB_SERVER_URL")) if not server_url: fail("Could not determine the Gitea server URL from workflow environment.") owner = first_non_empty( repository.get("owner", {}).get("login") or repository.get("owner_name"), repository_parts[0], env_optional("GITEA_REPOSITORY_OWNER"), ) repo = first_non_empty( repository.get("name"), repository_parts[1], env_optional("GITEA_REPOSITORY_NAME"), ) if not owner or not repo: fail("Could not determine repository owner/name from the release event.") attach_to_gitea_release( server_url=server_url, owner=owner, repo=repo, release_id=int(release["id"]), artifact=artifact, token=env_required("BAGTABS_GITEA_TOKEN"), ) game_versions = split_csv(env_required("MINECRAFT_VERSIONS")) loaders = split_csv(env_required("MOD_LOADERS")) upload_to_modrinth( artifact=artifact, token=env_required("MODRINTH_TOKEN"), project_id=env_required("MODRINTH_PROJECT_ID"), version_number=version_number, version_name=version_name, changelog=changelog, release_channel=release_channel, game_versions=game_versions, loaders=loaders, ) curseforge_token = env_required("CURSEFORGE_TOKEN") curseforge_api_base = env_optional("CURSEFORGE_API_BASE", "https://minecraft.curseforge.com") curseforge_game_version_ids = resolve_curseforge_game_version_ids( token=curseforge_token, game_version_names=split_csv(env_optional("CURSEFORGE_GAME_VERSION_NAMES")), explicit_ids=split_csv(env_optional("CURSEFORGE_GAME_VERSION_IDS")), api_base=curseforge_api_base, ) upload_to_curseforge( artifact=artifact, token=curseforge_token, project_id=env_required("CURSEFORGE_PROJECT_ID"), display_name=version_name, changelog=changelog, release_channel=release_channel, game_version_ids=curseforge_game_version_ids, api_base=curseforge_api_base, ) print("Release publishing complete.") if __name__ == "__main__": main()