398 lines
12 KiB
Python
398 lines
12 KiB
Python
#!/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()
|