diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..5dd08d0 --- /dev/null +++ b/.gitea/workflows/release.yml @@ -0,0 +1,43 @@ +name: Release + +on: + release: + types: + - published + +jobs: + publish: + runs-on: ubuntu-latest + env: + GITEA_SERVER_URL: ${{ gitea.server_url }} + BAGTABS_GITEA_TOKEN: ${{ secrets.BAGTABS_GITEA_TOKEN }} + MODRINTH_TOKEN: ${{ secrets.MODRINTH_TOKEN }} + CURSEFORGE_TOKEN: ${{ secrets.CURSEFORGE_TOKEN }} + MODRINTH_PROJECT_ID: ${{ vars.MODRINTH_PROJECT_ID }} + CURSEFORGE_PROJECT_ID: ${{ vars.CURSEFORGE_PROJECT_ID }} + MINECRAFT_VERSIONS: ${{ vars.MINECRAFT_VERSIONS }} + MOD_LOADERS: ${{ vars.MOD_LOADERS }} + CURSEFORGE_GAME_VERSION_NAMES: ${{ vars.CURSEFORGE_GAME_VERSION_NAMES }} + CURSEFORGE_GAME_VERSION_IDS: ${{ vars.CURSEFORGE_GAME_VERSION_IDS }} + RELEASE_ARTIFACT_GLOB: ${{ vars.RELEASE_ARTIFACT_GLOB }} + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup JDK 21 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "21" + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + + - name: Make Gradle wrapper executable + run: chmod +x ./gradlew + + - name: Build release jar + run: ./gradlew --no-daemon clean build + + - name: Publish release artifacts + run: python3 ./scripts/publish_release.py diff --git a/gradle.properties b/gradle.properties index c384d0d..40c6c3d 100644 --- a/gradle.properties +++ b/gradle.properties @@ -30,9 +30,9 @@ mod_id=bagtabs # The human-readable display name for the mod. mod_name=Bag Tabs # The license of the mod. Review your options at https://choosealicense.com/. All Rights Reserved is the default. -mod_license=All Rights Reserved +mod_license=MIT # The mod version. See https://semver.org/ -mod_version=1.0.0 +mod_version=0.1.0 # The group ID for the mod. It is only important when publishing as an artifact to a Maven repository. # This should match the base package used for the mod sources. # See https://maven.apache.org/guides/mini/guide-naming-conventions.html diff --git a/project_picture.png b/project_picture.png new file mode 100644 index 0000000..38934cb Binary files /dev/null and b/project_picture.png differ diff --git a/project_picture_full.png b/project_picture_full.png new file mode 100644 index 0000000..d53f9c0 Binary files /dev/null and b/project_picture_full.png differ diff --git a/scripts/publish_release.py b/scripts/publish_release.py new file mode 100644 index 0000000..1e63112 --- /dev/null +++ b/scripts/publish_release.py @@ -0,0 +1,397 @@ +#!/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()