ready for initial release
Some checks failed
Release / publish (release) Has been cancelled

This commit is contained in:
trunksbomb
2026-03-23 10:58:48 -04:00
parent 5d283909db
commit 5c5c35871d
5 changed files with 442 additions and 2 deletions

View 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

View File

@@ -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

BIN
project_picture.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 798 B

BIN
project_picture_full.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

397
scripts/publish_release.py Normal file
View 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()