Skip to content

ci: publish Edge builds to Modrinth and CurseForge #243

ci: publish Edge builds to Modrinth and CurseForge

ci: publish Edge builds to Modrinth and CurseForge #243

Workflow file for this run

name: Edge CI Release
on:
pull_request:
push:
branches:
- main
- canary
- canary/**
- beta
- beta/**
- release
- release/**
schedule:
- cron: "0 19 * * *"
workflow_dispatch:
permissions:
contents: write
env:
PRODUCT_CODE: edge
ARTIFACT_SOURCE_TYPE: external_url
jobs:
# Windows SMTC bridge DLL. The binaries are not committed to the repository —
# they are built here and injected into src/main/resources before packaging,
# so the shipped jar can never contain a stale binary built from older C++.
build-native:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- name: Build fpsmaster-smtc (x64 + x86)
shell: pwsh
run: |
cmake -S native/smtc -B native/smtc/build-x64 -A x64
cmake --build native/smtc/build-x64 --config Release
cmake -S native/smtc -B native/smtc/build-win32 -A Win32
cmake --build native/smtc/build-win32 --config Release
- name: Collect DLLs
shell: pwsh
run: |
New-Item -ItemType Directory -Force -Path dist/x64, dist/x86 | Out-Null
Copy-Item native/smtc/build-x64/Release/fpsmaster-smtc.dll dist/x64/
Copy-Item native/smtc/build-win32/Release/fpsmaster-smtc.dll dist/x86/
Get-ChildItem -Recurse dist | Select-Object FullName, Length
- name: Upload native artifacts
uses: actions/upload-artifact@v4
with:
name: smtc-native
path: dist
if-no-files-found: error
build:
needs: build-native
runs-on: ubuntu-latest
outputs:
should_publish: ${{ steps.decision.outputs.should_publish }}
channel: ${{ steps.channel.outputs.channel }}
should_build: ${{ steps.decision.outputs.should_build }}
release_tag: ${{ steps.meta.outputs.release_tag }}
version_name: ${{ steps.meta.outputs.version_name }}
asset_name: ${{ steps.meta.outputs.asset_name }}
checksum: ${{ steps.meta.outputs.checksum }}
file_size: ${{ steps.meta.outputs.file_size }}
changelog: ${{ steps.meta.outputs.changelog }}
commit_hash: ${{ steps.meta.outputs.commit_hash }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Resolve release channel
id: channel
shell: bash
run: |
event_name="${GITHUB_EVENT_NAME}"
ref="${GITHUB_REF_NAME}"
channel=""
should_publish="false"
if [ "$event_name" = "schedule" ]; then
channel="nightly"
should_publish="true"
else
case "$ref" in
main)
channel=""
should_publish="false"
;;
nightly|nightly/*)
channel="nightly"
should_publish="false"
;;
canary|canary/*)
channel="canary"
should_publish="true"
;;
beta|beta/*)
channel="beta"
should_publish="true"
;;
release|release/*)
channel="release"
should_publish="true"
;;
esac
fi
echo "channel=$channel" >> "$GITHUB_OUTPUT"
echo "should_publish=$should_publish" >> "$GITHUB_OUTPUT"
- name: Detect nightly changes
id: nightly
if: github.event_name == 'schedule'
shell: bash
run: |
git fetch --force --tags origin main
current_short_sha="$(git rev-parse --short=8 origin/main)"
latest_nightly_tag="$(git tag -l 'nightly-*' --sort=-v:refname | head -n 1)"
latest_nightly_sha=""
if [ -n "$latest_nightly_tag" ]; then
latest_nightly_sha="${latest_nightly_tag##*-}"
fi
should_build="true"
should_publish="${{ steps.channel.outputs.should_publish }}"
if [ "$current_short_sha" = "$latest_nightly_sha" ]; then
should_build="false"
should_publish="false"
fi
echo "should_build=$should_build" >> "$GITHUB_OUTPUT"
echo "should_publish=$should_publish" >> "$GITHUB_OUTPUT"
- name: Finalize build decision
id: decision
shell: bash
run: |
should_build="true"
should_publish="${{ steps.channel.outputs.should_publish }}"
if [ "${GITHUB_EVENT_NAME}" = "schedule" ]; then
should_build="${{ steps.nightly.outputs.should_build }}"
should_publish="${{ steps.nightly.outputs.should_publish }}"
fi
echo "should_build=$should_build" >> "$GITHUB_OUTPUT"
echo "should_publish=$should_publish" >> "$GITHUB_OUTPUT"
- name: Set up JDK 17
if: steps.decision.outputs.should_build == 'true'
uses: actions/setup-java@v4
with:
java-version: "17"
distribution: "temurin"
- name: Setup Gradle
if: steps.decision.outputs.should_build == 'true'
uses: gradle/actions/setup-gradle@v3
- name: Inject SMTC native libraries
if: steps.decision.outputs.should_build == 'true'
uses: actions/download-artifact@v4
with:
name: smtc-native
path: src/main/resources/native/windows
- name: Verify native libraries are present
if: steps.decision.outputs.should_build == 'true'
shell: bash
run: |
for arch in x64 x86; do
dll="src/main/resources/native/windows/$arch/fpsmaster-smtc.dll"
if [ ! -s "$dll" ]; then
echo "Missing or empty $dll — the jar would silently ship without SMTC" >&2
exit 1
fi
echo "$arch: $(stat -c%s "$dll") bytes, sha256 $(sha256sum "$dll" | awk '{print $1}')"
done
- name: Build with Gradle Wrapper
if: steps.decision.outputs.should_build == 'true'
shell: bash
run: |
chmod +x ./gradlew
./gradlew build
- name: Capture artifact metadata
id: meta
if: steps.decision.outputs.should_build == 'true'
shell: bash
run: |
artifact_path="$(find build/libs -maxdepth 1 -type f -name '*.jar' | head -n 1)"
if [ -z "$artifact_path" ]; then
echo "No jar artifact found in build/libs" >&2
exit 1
fi
asset_name="$(basename "$artifact_path")"
commit_hash="${GITHUB_SHA}"
short_sha="$(printf '%s' "$commit_hash" | cut -c1-8)"
version_name="${{ steps.channel.outputs.channel }}-${GITHUB_RUN_NUMBER}-${short_sha}"
release_tag="${{ steps.channel.outputs.channel }}-${GITHUB_RUN_NUMBER}-${short_sha}"
checksum="$(sha256sum "$artifact_path" | awk '{print $1}')"
file_size="$(stat -c%s "$artifact_path")"
{
echo "artifact_path=$artifact_path"
echo "asset_name=$asset_name"
echo "commit_hash=$commit_hash"
echo "version_name=$version_name"
echo "release_tag=$release_tag"
echo "checksum=$checksum"
echo "file_size=$file_size"
} >> "$GITHUB_OUTPUT"
{
echo "changelog<<EOF"
git log -1 --pretty=format:'%s%n%n%b'
echo
echo "EOF"
} >> "$GITHUB_OUTPUT"
- name: Upload build artifacts
if: steps.decision.outputs.should_build == 'true'
uses: actions/upload-artifact@v4
with:
name: edge-build
path: build/libs/*.jar
if-no-files-found: error
publish:
needs: build
if: github.event_name != 'pull_request' && needs.build.outputs.should_publish == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v4
with:
name: edge-build
path: dist
- name: Publish GitHub Release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ needs.build.outputs.release_tag }}
target_commitish: ${{ github.sha }}
name: Edge ${{ needs.build.outputs.version_name }}
body: ${{ needs.build.outputs.changelog }}
files: dist/*.jar
fail_on_unmatched_files: true
- name: Notify release API
shell: bash
env:
FPSMASTER_CI_API_BASE_URL: ${{ secrets.FPSMASTER_CI_API_BASE_URL }}
FPSMASTER_CI_UPLOAD_TOKEN: ${{ secrets.FPSMASTER_CI_UPLOAD_TOKEN }}
CHANGELOG: ${{ needs.build.outputs.changelog }}
run: |
if [ -z "$FPSMASTER_CI_API_BASE_URL" ] || [ -z "$FPSMASTER_CI_UPLOAD_TOKEN" ]; then
echo "Release API secrets are not configured" >&2
exit 1
fi
artifact_path="$(find dist -maxdepth 1 -type f -name '*.jar' | head -n 1)"
if [ -z "$artifact_path" ]; then
echo "No jar artifact found in dist" >&2
exit 1
fi
upload_response="$(curl --fail --show-error --silent \
-X POST \
-H "X-CI-Token: ${FPSMASTER_CI_UPLOAD_TOKEN}" \
-F "file=@${artifact_path}" \
"${FPSMASTER_CI_API_BASE_URL%/}/api/v1/launcher/releases/upload")"
upload_json="$(printf '%s' "$upload_response" | python -c 'import json,sys; data=json.load(sys.stdin); print(json.dumps(data["data"]))')"
download_url="$(printf '%s' "$upload_json" | python -c 'import json,sys; print(json.load(sys.stdin)["url"])')"
file_bucket="$(printf '%s' "$upload_json" | python -c 'import json,sys; print((json.load(sys.stdin).get("bucket") or ""))')"
file_key="$(printf '%s' "$upload_json" | python -c 'import json,sys; print((json.load(sys.stdin).get("key") or ""))')"
changelog_json="$(printf '%s' "$CHANGELOG" | python -c 'import json,sys; print(json.dumps(sys.stdin.read()))')"
cat <<EOF > payload.json
{
"productCode": "${PRODUCT_CODE}",
"channelCode": "${{ needs.build.outputs.channel }}",
"versionName": "${{ needs.build.outputs.version_name }}",
"commitHash": "${{ needs.build.outputs.commit_hash }}",
"artifactSourceType": "UPLOADED_FILE",
"downloadUrl": "${download_url}",
"fileBucket": "${file_bucket}",
"fileKey": "${file_key}",
"fileSize": ${{ needs.build.outputs.file_size }},
"checksum": "${{ needs.build.outputs.checksum }}",
"recommended": true,
"mandatory": false,
"changelog": ${changelog_json}
}
EOF
curl --fail --show-error --silent \
-X POST \
-H "Content-Type: application/json" \
-H "X-CI-Token: ${FPSMASTER_CI_UPLOAD_TOKEN}" \
--data @payload.json \
"${FPSMASTER_CI_API_BASE_URL%/}/api/v1/launcher/releases/ci"
- name: Publish to Modrinth
shell: bash
env:
MODRINTH_TOKEN: ${{ secrets.MODRINTH_TOKEN }}
MODRINTH_PROJECT_ID: ${{ secrets.MODRINTH_PROJECT_ID }}
CHANGELOG: ${{ needs.build.outputs.changelog }}
VERSION_NAME: ${{ needs.build.outputs.version_name }}
CHANNEL: ${{ needs.build.outputs.channel }}
COMMIT_HASH: ${{ needs.build.outputs.commit_hash }}
run: |
if [ -z "$MODRINTH_TOKEN" ]; then
echo "MODRINTH_TOKEN not configured; skipping Modrinth publish"
exit 0
fi
project_id="${MODRINTH_PROJECT_ID:-EYsVyax4}"
artifact_path="$(find dist -maxdepth 1 -type f -name '*.jar' | head -n 1)"
if [ -z "$artifact_path" ]; then
echo "No jar artifact found in dist" >&2
exit 1
fi
# Modrinth version_number: URL-safe, unique, max 32 chars.
short_sha="$(printf '%s' "$COMMIT_HASH" | cut -c1-7)"
base_vn="$(printf '%s' "$VERSION_NAME" | tr -cd 'A-Za-z0-9._-')"
case "$CHANNEL" in
release) version_type="release"; version_number="$base_vn" ;;
beta) version_type="beta"; version_number="${base_vn}-${short_sha}" ;;
*) version_type="alpha"; version_number="${base_vn}-${short_sha}" ;;
esac
version_number="$(printf '%s' "$version_number" | cut -c1-32)"
if [ -z "$version_number" ]; then
version_number="edge-${short_sha}"
fi
CHANGELOG="$CHANGELOG" VERSION_NAME="$VERSION_NAME" VERSION_NUMBER="$version_number" \
VERSION_TYPE="$version_type" PROJECT_ID="$project_id" python - <<'PY'
import json, os

Check failure on line 352 in .github/workflows/ci-release.yml

View workflow run for this annotation

GitHub Actions / .github/workflows/ci-release.yml

Invalid workflow file

You have an error in your yaml syntax on line 352
with open("modrinth-data.json", "w", encoding="utf-8") as f:
json.dump({
"name": f"Edge {os.environ['VERSION_NAME']}",
"version_number": os.environ["VERSION_NUMBER"],
"changelog": os.environ.get("CHANGELOG") or "",
"dependencies": [],
"game_versions": ["1.8.9"],
"version_type": os.environ["VERSION_TYPE"],
"loaders": ["forge"],
"featured": os.environ["VERSION_TYPE"] == "release",
"project_id": os.environ["PROJECT_ID"],
"file_parts": ["file"],
"primary_file": "file",
}, f)
PY
echo "Publishing $artifact_path to Modrinth project $project_id as $version_number ($version_type)"
curl --fail --show-error --silent \
-X POST \
-H "Authorization: ${MODRINTH_TOKEN}" \
-F "data=@modrinth-data.json;type=application/json" \
-F "file=@${artifact_path}" \
"https://api.modrinth.com/v2/version" \
| python -c 'import json,sys; v=json.load(sys.stdin); print("Modrinth version id:", v.get("id"), "url: https://modrinth.com/mod/fpsmaster/version/" + (v.get("id") or ""))'
- name: Publish to CurseForge
shell: bash
env:
CURSEFORGE_TOKEN: ${{ secrets.CURSEFORGE_TOKEN }}
CURSEFORGE_PROJECT_ID: ${{ secrets.CURSEFORGE_PROJECT_ID }}
CHANGELOG: ${{ needs.build.outputs.changelog }}
VERSION_NAME: ${{ needs.build.outputs.version_name }}
CHANNEL: ${{ needs.build.outputs.channel }}
run: |
if [ -z "$CURSEFORGE_TOKEN" ]; then
echo "CURSEFORGE_TOKEN not configured; skipping CurseForge publish"
exit 0
fi
project_id="${CURSEFORGE_PROJECT_ID:-1081006}"
artifact_path="$(find dist -maxdepth 1 -type f -name '*.jar' | head -n 1)"
if [ -z "$artifact_path" ]; then
echo "No jar artifact found in dist" >&2
exit 1
fi
case "$CHANNEL" in
release) release_type="release" ;;
beta) release_type="beta" ;;
*) release_type="alpha" ;;
esac
# Resolve numeric gameVersion IDs for Minecraft 1.8.9 + Forge (upload API requires IDs).
versions_json="$(curl --fail --show-error --silent \
-H "X-Api-Token: ${CURSEFORGE_TOKEN}" \
"https://minecraft.curseforge.com/api/game/versions")"
game_version_ids="$(VERSIONS_JSON="$versions_json" python - <<'PY'
import json, os, sys
versions = json.loads(os.environ["VERSIONS_JSON"])
wanted = {"1.8.9", "Forge"}
ids = []
for v in versions:
name = (v.get("name") or "").strip()
if name in wanted:
ids.append(int(v["id"]))
# Prefer exact name matches; also accept slug-style "Minecraft 1.8.9"
if len(ids) < 2:
for v in versions:
name = (v.get("name") or "").strip()
if name in ("Minecraft 1.8.9", "1.8.9-Forge", "Forge") or name.endswith("1.8.9"):
vid = int(v["id"])
if vid not in ids:
ids.append(vid)
ids = sorted(set(ids))
if not ids:
print("Could not resolve CurseForge game version IDs for 1.8.9 / Forge", file=sys.stderr)
sys.exit(1)
print(",".join(str(i) for i in ids))
print("Resolved CurseForge gameVersions:", ids, file=sys.stderr)
PY
)"
IFS=',' read -r -a gv_arr <<< "$game_version_ids"
gv_json="$(printf '%s\n' "${gv_arr[@]}" | python -c 'import json,sys; print(json.dumps([int(x) for x in sys.stdin if x.strip()]))')"
CHANGELOG="$CHANGELOG" VERSION_NAME="$VERSION_NAME" RELEASE_TYPE="$release_type" \
GAME_VERSIONS_JSON="$gv_json" python - <<'PY'
import json, os
with open("curseforge-metadata.json", "w", encoding="utf-8") as f:
json.dump({
"changelog": os.environ.get("CHANGELOG") or f"Edge {os.environ['VERSION_NAME']}",
"changelogType": "markdown",
"displayName": f"Edge {os.environ['VERSION_NAME']}",
"gameVersions": json.loads(os.environ["GAME_VERSIONS_JSON"]),
"releaseType": os.environ["RELEASE_TYPE"],
}, f)
PY
echo "Publishing $artifact_path to CurseForge project $project_id ($release_type)"
curl --fail --show-error --silent \
-X POST \
-H "X-Api-Token: ${CURSEFORGE_TOKEN}" \
-F "metadata=<curseforge-metadata.json;type=application/json" \
-F "file=@${artifact_path}" \
"https://minecraft.curseforge.com/api/projects/${project_id}/upload-file" \
| python -c 'import json,sys; r=json.load(sys.stdin); print("CurseForge file id:", r.get("id"))'