This commit is contained in:
43
.gitea/workflows/release.yml
Normal file
43
.gitea/workflows/release.yml
Normal file
@@ -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
|
||||||
@@ -30,9 +30,9 @@ mod_id=bagtabs
|
|||||||
# The human-readable display name for the mod.
|
# The human-readable display name for the mod.
|
||||||
mod_name=Bag Tabs
|
mod_name=Bag Tabs
|
||||||
# The license of the mod. Review your options at https://choosealicense.com/. All Rights Reserved is the default.
|
# 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/
|
# 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.
|
# 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.
|
# This should match the base package used for the mod sources.
|
||||||
# See https://maven.apache.org/guides/mini/guide-naming-conventions.html
|
# See https://maven.apache.org/guides/mini/guide-naming-conventions.html
|
||||||
|
|||||||
BIN
project_picture.png
Normal file
BIN
project_picture.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 798 B |
BIN
project_picture_full.png
Normal file
BIN
project_picture_full.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
397
scripts/publish_release.py
Normal file
397
scripts/publish_release.py
Normal file
@@ -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()
|
||||||
Reference in New Issue
Block a user