ci: publish Edge builds to Modrinth and CurseForge #243
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
| 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"))' | ||