Skip to content

fix(connect): preserve tunnel delivery and late wrappers (#142) #152

fix(connect): preserve tunnel delivery and late wrappers (#142)

fix(connect): preserve tunnel delivery and late wrappers (#142) #152

Workflow file for this run

name: Release
on:
# Trigger on GitHub UI release creation
release:
types: [published]
# release-please creates tags/releases with GITHUB_TOKEN, which does not
# trigger downstream workflows. release-please.yml dispatches this workflow
# on the tag ref so release artifacts are built and uploaded.
workflow_dispatch:
inputs:
release_tag:
description: Existing tag to publish or repair. Empty uses the workflow ref.
required: false
type: string
# Trigger on branch push for pre-releases
push:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # Fetch all history for git describe
ref: ${{ inputs.release_tag || github.ref }}
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '17'
cache: 'gradle'
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v3
- name: Build
env:
# These values are compiled into Constants.class. Keep tagged builds independent of
# which workflow ref launched a retry and of GitHub's ever-increasing run number.
GIT_BRANCH: ${{ inputs.release_tag || github.event.release.tag_name || github.ref_name }}
BUILD_NUMBER: ${{ github.event_name == 'push' && github.run_number || 0 }}
run: ./gradlew build
- name: Get version
id: version
run: |
VERSION=$(./gradlew properties -q | grep "^version:" | awk '{print $2}')
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "Building version: $VERSION"
- name: Resolve release tag
id: release-tag
if: github.event_name == 'release' || github.event_name == 'workflow_dispatch'
run: |
TAG="${{ inputs.release_tag }}"
if [ -z "$TAG" ]; then
TAG="${{ github.event.release.tag_name }}"
fi
if [ -z "$TAG" ]; then
TAG="${GITHUB_REF_NAME}"
fi
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
echo "Release tag: $TAG"
# For release events: upload artifacts to the release
- name: Upload Release Artifacts
if: github.event_name == 'release' || github.event_name == 'workflow_dispatch'
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ steps.release-tag.outputs.tag }}
# Versioned assets are immutable. A retry verifies/reuses them instead of silently
# replacing bytes already referenced by Hangar or Modrinth.
overwrite_files: false
files: |
spigot/build/libs/connect-spigot.jar
velocity/build/libs/connect-velocity.jar
bungee/build/libs/connect-bungee.jar
LICENSE
# Also update "latest" release with stable file names for download sites
- name: Update Latest Release
if: github.event_name == 'release' || github.event_name == 'workflow_dispatch'
uses: softprops/action-gh-release@v2
with:
tag_name: latest
name: "Latest Release (${{ steps.release-tag.outputs.tag }})"
prerelease: false
overwrite_files: true
files: |
spigot/build/libs/connect-spigot.jar
velocity/build/libs/connect-velocity.jar
bungee/build/libs/connect-bungee.jar
LICENSE
body: |
Latest stable release.
**Version:** ${{ steps.release-tag.outputs.tag }}
Download links (stable URLs):
- [connect-spigot.jar](https://github.com/${{ github.repository }}/releases/download/latest/connect-spigot.jar)
- [connect-velocity.jar](https://github.com/${{ github.repository }}/releases/download/latest/connect-velocity.jar)
- [connect-bungee.jar](https://github.com/${{ github.repository }}/releases/download/latest/connect-bungee.jar)
# For branch push: create/update pre-release
- name: Prepare Pre-Release Artifacts
if: github.event_name == 'push'
run: |
mkdir -p prerelease
cp spigot/build/libs/connect-spigot.jar prerelease/connect-spigot-prerelease.jar
cp velocity/build/libs/connect-velocity.jar prerelease/connect-velocity-prerelease.jar
cp bungee/build/libs/connect-bungee.jar prerelease/connect-bungee-prerelease.jar
- name: Update Pre-Release
if: github.event_name == 'push'
uses: softprops/action-gh-release@v2
with:
tag_name: latest-prerelease
name: "Latest Pre-Release (${{ steps.version.outputs.version }})"
prerelease: true
overwrite_files: true
files: |
prerelease/*.jar
LICENSE
body: |
Automated pre-release build from `main` branch.
**Version:** ${{ steps.version.outputs.version }}
**Commit:** ${{ github.sha }}
⚠️ This is a development build and may be unstable.
# A release that publishes no downloadable asset is indistinguishable
# from a healthy one: the run is green, the release page exists, and
# the hole only surfaces later when a server owner's download link
# 404s. Nothing here ever re-read the release that actually landed,
# so an upload that silently shipped nothing looked exactly like a
# successful release.
#
# This re-reads the PUBLISHED release from the API instead of trusting
# the upload steps above. Trusting the steps we just ran would rebuild
# the same "green run, empty artifact" defect one layer up: the upload
# action can skip files, partially fail, or be silently gated off, and
# only the landed release tells the truth. Assert on the fact.
- name: Verify published release assets
id: verify_release_assets
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_TAG: ${{ steps.release-tag.outputs.tag }}
run: |
set -euo pipefail
# Verify every release this run publishes. The release path writes
# both the version tag and the stable "latest" release the download
# links point at; the push path writes "latest-prerelease".
if [ "$GITHUB_EVENT_NAME" = "push" ]; then
TARGETS="latest-prerelease"
else
if [ -z "$RELEASE_TAG" ]; then
echo "::error::Release tag is empty; cannot verify the published release."
exit 1
fi
TARGETS="$RELEASE_TAG latest"
fi
# A non-empty asset list is NOT proof of a usable release. Only an
# uploaded, non-empty asset matching the positive plugin-JAR allowlist
# counts; every other asset, including source.tar.gz, is rejected.
# Require a real downloadable platform jar by name instead.
BUILD_FILTER='[.assets[]
| select(.state == "uploaded")
| select(.size > 0)
| select(.name | test("^connect-(spigot|velocity|bungee).*\\.jar$"))]'
verify_release() {
local tag="$1"
local attempt RELEASE_JSON ASSET_COUNT BUILD_COUNT BUILD_NAMES
local PROBE PROBE_URL PROBE_CODE
RELEASE_JSON=""
ASSET_COUNT=0
BUILD_COUNT=0
BUILD_NAMES=""
PROBE=""
PROBE_URL=""
PROBE_CODE=""
# Asset visibility is eventually consistent right after upload, so
# poll briefly before declaring the release empty.
for attempt in $(seq 1 12); do
if ! RELEASE_JSON=$(gh api "/repos/$GITHUB_REPOSITORY/releases/tags/$tag"); then
echo "Release $tag is not readable yet (attempt $attempt); waiting..."
RELEASE_JSON=""
sleep 10
continue
fi
ASSET_COUNT=$(echo "$RELEASE_JSON" | jq '[.assets[] | select(.state == "uploaded")] | length')
BUILD_COUNT=$(echo "$RELEASE_JSON" | jq "$BUILD_FILTER | length")
BUILD_NAMES=$(echo "$RELEASE_JSON" | jq -r "$BUILD_FILTER | .[].name")
PROBE=""
PROBE_URL=""
PROBE_CODE=""
if [ "$BUILD_COUNT" -gt 0 ]; then
PROBE=$(echo "$BUILD_NAMES" | head -n1)
PROBE_URL="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/releases/download/$tag/$PROBE"
if ! PROBE_CODE=$(curl -sSL -o /dev/null -w '%{http_code}' -r 0-0 "$PROBE_URL"); then
PROBE_CODE="000"
fi
fi
if [ "$ASSET_COUNT" -gt 0 ] && [ "$BUILD_COUNT" -gt 0 ]; then
case "$PROBE_CODE" in
200|206) break ;;
esac
fi
echo "Published assets are incomplete on $tag (attempt $attempt); waiting..."
sleep 10
done
echo "--- $tag ---"
if [ -n "$RELEASE_JSON" ]; then
echo "$RELEASE_JSON" | jq -r '.assets[] | "\(.name)\t\(.size)\t\(.state)"'
fi
echo "Real build artifacts published ($BUILD_COUNT):"
echo "${BUILD_NAMES:- (none)}"
if [ -z "$RELEASE_JSON" ]; then
echo "::error::Release $tag could not be read back from the API; the release did not land."
exit 1
fi
if [ "$ASSET_COUNT" -eq 0 ]; then
echo "::error::Release $tag published with ZERO downloadable assets."
echo "::error::A release with no artifact silently did not happen. Failing loudly."
exit 1
fi
# Wrong-artifact-type is its own failure mode, distinct from empty.
# It is the more dangerous one because the release looks populated.
if [ "$BUILD_COUNT" -eq 0 ]; then
echo "::error::Release $tag has $ASSET_COUNT asset(s) but NO real build artifact."
echo "::error::No asset matched the positive plugin-JAR allowlist; every non-matching asset, including source.tar.gz, was rejected."
echo "::error::A positive asset count is not a release; a downloadable build is."
exit 1
fi
# Finally prove one build is actually served, not merely listed by
# the API. Range-request the first byte: a released asset that 404s
# or is empty fails here rather than in a server owner's download.
if [ "$PROBE_CODE" != "200" ] && [ "$PROBE_CODE" != "206" ]; then
echo "::error::Build artifact $PROBE is listed on $tag but not downloadable (HTTP $PROBE_CODE); the build is undownloadable."
echo "::error::$PROBE_URL"
exit 1
fi
echo "OK: $tag publishes $BUILD_COUNT real build artifact(s) of $ASSET_COUNT assets;"
echo "OK: $PROBE downloads (HTTP $PROBE_CODE)."
}
for target in $TARGETS; do
verify_release "$target"
done
# Publish the same verified release to Hangar alongside Modrinth. Hangar
# has three platform names: PAPER, VELOCITY and WATERFALL. Its WATERFALL
# slot is the correct home for the BungeeCord-compatible jar; attaching
# that jar to PAPER or VELOCITY would present operators with a download
# their platform cannot load.
#
# Resolve platform versions from Hangar itself on every release. A
# hard-coded compatibility list quietly ages while releases continue to
# look healthy. The Paper floor comes from the plugin descriptor the jar
# was built from; proxy compatibility is bounded by the versions Hangar
# currently accepts for those platforms.
#
# The API key needs create_version and edit_page. edit_page keeps the
# checked-in resource page authoritative instead of leaving release
# automation healthy beside stale installation guidance.
- name: Publish to Hangar
if: github.event_name == 'release' || github.event_name == 'workflow_dispatch'
env:
HANGAR_API_TOKEN: ${{ secrets.HANGAR_API_TOKEN }}
RELEASE_TAG: ${{ steps.release-tag.outputs.tag }}
# The checkout may be an old release tag that predates the Hangar
# page source. Read it from the exact commit supplying this workflow.
WORKFLOW_SHA: ${{ github.workflow_sha }}
run: |
set -euo pipefail
if [ -z "${HANGAR_API_TOKEN:-}" ]; then
echo "::error::HANGAR_API_TOKEN is empty; refusing to skip publishing silently."
echo "::error::Create a Hangar API key with create_version and edit_page permissions."
exit 1
fi
if [ -z "${RELEASE_TAG:-}" ]; then
echo "::error::Release tag is empty; cannot publish a Hangar version."
exit 1
fi
if [ -z "${WORKFLOW_SHA:-}" ]; then
echo "::error::Workflow commit is empty; cannot load the authoritative Hangar page."
exit 1
fi
API="https://hangar.papermc.io/api/v1"
PROJECT="Connect"
AUTHOR="minekube"
UA="minekube/connect-java release workflow (+https://github.com/minekube/connect-java)"
TMP="$(mktemp -d)"
trap 'rm -rf "$TMP"' EXIT
# Authenticate without placing the API key in curl's process
# arguments or on disk. Hangar exchanges it for a short-lived JWT.
ENCODED_KEY="$(jq -rn --arg key "$HANGAR_API_TOKEN" '$key | @uri')"
AUTH_CODE="$(
printf 'url = "%s/authenticate?apiKey=%s"\n' "$API" "$ENCODED_KEY" \
| curl -sS -K - -X POST -A "$UA" -o "$TMP/auth.json" -w '%{http_code}'
)"
if [ "$AUTH_CODE" != "200" ]; then
echo "::error::HANGAR_API_TOKEN was refused (HTTP $AUTH_CODE)."
cat "$TMP/auth.json" || true
exit 1
fi
HANGAR_JWT="$(jq -r '.token // ""' "$TMP/auth.json")"
if [ -z "$HANGAR_JWT" ]; then
echo "::error::Hangar authentication returned no JWT."
exit 1
fi
# The JWT reaches curl through config on stdin. It is not written to
# disk and does not appear in the process argument list.
api() {
local out="$1"
shift
printf 'header = "Authorization: HangarAuth %s"\n' "$HANGAR_JWT" \
| curl -sS -K - -A "$UA" -o "$out" -w '%{http_code}' "$@"
}
# Sync the public resource page before creating an immutable version.
# If the token lacks edit_page, fail before partially publishing.
# Backfills check out the release tag, which may predate this file,
# so fetch it from the immutable commit that supplied the workflow.
curl -sS --fail -A "$UA" \
"https://raw.githubusercontent.com/$GITHUB_REPOSITORY/$WORKFLOW_SHA/.github/hangar-description.md" \
-o "$TMP/hangar-description.md"
if [ ! -s "$TMP/hangar-description.md" ]; then
echo "::error::The authoritative Hangar resource page is empty."
exit 1
fi
jq -n --rawfile content "$TMP/hangar-description.md" \
'{path: "", content: $content}' > "$TMP/page.json"
PAGE_CODE="$(api "$TMP/page-response.json" \
-X PATCH "$API/pages/edit/$PROJECT" \
-H 'Content-Type: application/json' \
--data-binary "@$TMP/page.json")"
if [ "$PAGE_CODE" = "401" ] || [ "$PAGE_CODE" = "403" ]; then
echo "::error::HANGAR_API_TOKEN cannot edit the Connect resource page (HTTP $PAGE_CODE)."
echo "::error::Add the edit_page permission to the Hangar API key."
cat "$TMP/page-response.json" || true
exit 1
fi
if [ "$PAGE_CODE" != "200" ]; then
echo "::error::Hangar rejected the resource-page update (HTTP $PAGE_CODE)."
cat "$TMP/page-response.json" || true
exit 1
fi
curl -sS --fail -A "$UA" \
"$API/pages/main/$AUTHOR/$PROJECT" -o "$TMP/public-page.md"
if [ "$(cat "$TMP/public-page.md")" != "$(cat "$TMP/hangar-description.md")" ]; then
echo "::error::Hangar's public resource page differs from the checked-in page."
diff -u "$TMP/hangar-description.md" "$TMP/public-page.md" || true
exit 1
fi
# Hangar returns accepted version identifiers newest-first. Flatten
# each platform at publish time and keep Paper only down to the
# api-version floor declared by the plugin being shipped.
for platform in PAPER VELOCITY WATERFALL; do
curl -sS --fail -A "$UA" \
"$API/platforms/$platform/versions" \
-o "$TMP/$platform-versions.json"
done
API_FLOOR="$(awk '/^api-version:/ {print $2; exit}' \
spigot/src/main/resources/plugin.yml)"
if [ -z "$API_FLOOR" ]; then
echo "::error::Could not read api-version from spigot/src/main/resources/plugin.yml."
exit 1
fi
flatten_versions='[
.[] |
if (.subVersions | length) > 0
then .subVersions[]
else .version
end
]'
PAPER_VERSIONS="$(jq -c --arg floor "$API_FLOOR" "
$flatten_versions
| (index(\$floor)) as \$i
| if \$i == null then
error(\"declared api-version \(\$floor) is not a Hangar Paper version\")
else .[0:\$i + 1] end
" "$TMP/PAPER-versions.json")"
# Connect's existing compatibility contract starts at Velocity 3.0.
# Hangar also carries historical 1.x identifiers, which this plugin
# has never advertised and must not acquire merely because the API
# returns them.
VELOCITY_FLOOR="3.0"
VELOCITY_VERSIONS="$(jq -c --arg floor "$VELOCITY_FLOOR" "
$flatten_versions
| (index(\$floor)) as \$i
| if \$i == null then
error(\"Velocity floor \(\$floor) is not a Hangar version\")
else .[0:\$i + 1] end
" "$TMP/VELOCITY-versions.json")"
WATERFALL_VERSIONS="$(jq -c "$flatten_versions" "$TMP/WATERFALL-versions.json")"
if [ "$(echo "$PAPER_VERSIONS" | jq 'length')" -eq 0 ] \
|| [ "$(echo "$VELOCITY_VERSIONS" | jq 'length')" -eq 0 ] \
|| [ "$(echo "$WATERFALL_VERSIONS" | jq 'length')" -eq 0 ]; then
echo "::error::Hangar returned an empty accepted-version list for a platform."
exit 1
fi
FILES_JSON='[]'
add_file() {
local platforms="$1"
local jar="$2"
local filename="$3"
local external_url
if [ ! -f "$jar" ]; then
echo "::error::$jar was not produced by this build; nothing to publish."
exit 1
fi
external_url="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/releases/download/$RELEASE_TAG/$filename"
FILES_JSON="$(jq -c \
--argjson platforms "$platforms" \
--arg external_url "$external_url" \
'. + [{platforms: $platforms, externalUrl: $external_url}]' <<<"$FILES_JSON")"
}
# The three shaded jars total about 178 MB, larger than Hangar's
# Cloudflare request limit. Hangar's documented API supports external
# files, so bind each platform to its immutable versioned GitHub
# release URL instead of using mutable "latest" links or trying to
# bypass the edge limit.
add_file '["PAPER"]' spigot/build/libs/connect-spigot.jar connect-spigot.jar
add_file '["VELOCITY"]' velocity/build/libs/connect-velocity.jar connect-velocity.jar
add_file '["WATERFALL"]' bungee/build/libs/connect-bungee.jar connect-bungee.jar
SPIGOT_SHA256="$(sha256sum spigot/build/libs/connect-spigot.jar | awk '{print $1}')"
VELOCITY_SHA256="$(sha256sum velocity/build/libs/connect-velocity.jar | awk '{print $1}')"
BUNGEE_SHA256="$(sha256sum bungee/build/libs/connect-bungee.jar | awk '{print $1}')"
CHANGELOG="$(
printf '%s\n\n%s\n%s\n%s\n%s\n' \
"Release notes: $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/releases/tag/$RELEASE_TAG" \
"SHA-256 (GitHub release assets):" \
"- PAPER / connect-spigot.jar: $SPIGOT_SHA256" \
"- VELOCITY / connect-velocity.jar: $VELOCITY_SHA256" \
"- WATERFALL / connect-bungee.jar: $BUNGEE_SHA256"
)"
jq -n \
--arg version "$RELEASE_TAG" \
--arg description "$CHANGELOG" \
--argjson files "$FILES_JSON" \
--argjson paper "$PAPER_VERSIONS" \
--argjson velocity "$VELOCITY_VERSIONS" \
--argjson waterfall "$WATERFALL_VERSIONS" \
'{
version: $version,
channel: "Release",
description: $description,
files: $files,
platformDependencies: {
PAPER: $paper,
VELOCITY: $velocity,
WATERFALL: $waterfall
},
pluginDependencies: {
PAPER: [],
VELOCITY: [],
WATERFALL: []
}
}' > "$TMP/version.json"
ENCODED_TAG="$(jq -rn --arg tag "$RELEASE_TAG" '$tag | @uri')"
VERSION_URL="$API/projects/$AUTHOR/$PROJECT/versions/$ENCODED_TAG"
EXISTING_CODE="$(curl -sS -A "$UA" -o "$TMP/existing.json" \
-w '%{http_code}' "$VERSION_URL")"
case "$EXISTING_CODE" in
200)
echo "Hangar already carries $RELEASE_TAG; verifying it instead of duplicating it."
;;
404)
UPLOAD_CODE="$(api "$TMP/uploaded.json" \
-X POST "$API/projects/$PROJECT/upload" \
-F "versionUpload=@$TMP/version.json;type=application/json")"
if [ "$UPLOAD_CODE" = "401" ] || [ "$UPLOAD_CODE" = "403" ]; then
echo "::error::HANGAR_API_TOKEN cannot create version $RELEASE_TAG (HTTP $UPLOAD_CODE)."
echo "::error::Add the create_version permission to the Hangar API key."
cat "$TMP/uploaded.json" || true
exit 1
fi
if [ "$UPLOAD_CODE" != "200" ]; then
echo "::error::Hangar rejected version $RELEASE_TAG (HTTP $UPLOAD_CODE)."
cat "$TMP/uploaded.json" || true
exit 1
fi
;;
*)
echo "::error::Unexpected HTTP $EXISTING_CODE reading Hangar version $RELEASE_TAG."
cat "$TMP/existing.json" || true
exit 1
;;
esac
# Read the public version back. The upload response only confirms the
# request; the listing and its downloads are the release contract.
# Hangar can expose a new version as public/unreviewed briefly before
# its automatic review completes, so readiness includes both states.
VERSION_CODE=000
VERSION_READY=false
for attempt in $(seq 1 12); do
VERSION_CODE="$(curl -sS -A "$UA" -o "$TMP/stored.json" \
-w '%{http_code}' "$VERSION_URL")"
if [ "$VERSION_CODE" = "200" ] &&
jq -e '.visibility == "public" and .reviewState == "reviewed"' \
"$TMP/stored.json" >/dev/null; then
VERSION_READY=true
break
fi
if [ "$VERSION_CODE" = "200" ]; then
STATE="$(jq -c '{visibility, reviewState}' "$TMP/stored.json")"
echo "Hangar version is not ready yet ($STATE, attempt $attempt); waiting..."
else
echo "Hangar version is not public yet (HTTP $VERSION_CODE, attempt $attempt); waiting..."
fi
sleep 10
done
if [ "$VERSION_READY" != "true" ]; then
echo "::error::Hangar version $RELEASE_TAG did not become public and reviewed."
if [ "$VERSION_CODE" = "200" ]; then
jq '{visibility, reviewState}' "$TMP/stored.json"
else
echo "::error::Last public read returned HTTP $VERSION_CODE."
fi
exit 1
fi
if ! jq -e '.downloads | keys | sort == ["PAPER", "VELOCITY", "WATERFALL"]' \
"$TMP/stored.json" >/dev/null; then
echo "::error::Hangar version does not expose exactly PAPER, VELOCITY and WATERFALL."
jq '.downloads | keys' "$TMP/stored.json"
exit 1
fi
if ! jq -e \
--argjson paper "$PAPER_VERSIONS" \
--argjson velocity "$VELOCITY_VERSIONS" \
--argjson waterfall "$WATERFALL_VERSIONS" \
'(.platformDependencies.PAPER | sort) == ($paper | sort)
and (.platformDependencies.VELOCITY | sort) == ($velocity | sort)
and (.platformDependencies.WATERFALL | sort) == ($waterfall | sort)' \
"$TMP/stored.json" >/dev/null; then
echo "::error::Hangar stored different compatibility metadata than this release declared."
jq '.platformDependencies' "$TMP/stored.json"
exit 1
fi
curl -sS --fail -A "$UA" \
"https://api.github.com/repos/$GITHUB_REPOSITORY/releases/tags/$ENCODED_TAG" \
-o "$TMP/github-release.json"
verify_platform() {
local platform="$1"
local jar="$2"
local filename="$3"
local want_url want_sha256 listed_url asset_digest asset_size asset_type
local got_sha256 got_size final_type magic
want_url="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/releases/download/$RELEASE_TAG/$filename"
want_sha256="$(sha256sum "$jar" | awk '{print $1}')"
listed_url="$(jq -r --arg platform "$platform" \
'.downloads[$platform].externalUrl // ""' "$TMP/stored.json")"
if [ "$listed_url" != "$want_url" ]; then
echo "::error::Hangar stored the wrong external URL for $platform."
echo "::error::wanted $want_url / stored ${listed_url:-<absent>}"
exit 1
fi
if ! jq -e --arg hash "$want_sha256" '.description | contains($hash)' \
"$TMP/stored.json" >/dev/null; then
echo "::error::Hangar's public version metadata omits $platform SHA-256."
exit 1
fi
asset_digest="$(jq -r --arg name "$filename" \
'first(.assets[] | select(.name == $name) | .digest) // ""' \
"$TMP/github-release.json")"
asset_size="$(jq -r --arg name "$filename" \
'first(.assets[] | select(.name == $name) | .size) // 0' \
"$TMP/github-release.json")"
asset_type="$(jq -r --arg name "$filename" \
'first(.assets[] | select(.name == $name) | .content_type) // ""' \
"$TMP/github-release.json")"
if [ "$asset_digest" != "sha256:$want_sha256" ]; then
echo "::error::GitHub's stored digest differs for $filename."
echo "::error::wanted sha256:$want_sha256 / stored ${asset_digest:-<absent>}"
exit 1
fi
curl -sSL --fail --retry 3 --retry-all-errors \
-D "$TMP/$platform.headers" \
"$VERSION_URL/$platform/download" -o "$TMP/$platform.jar"
got_sha256="$(sha256sum "$TMP/$platform.jar" | awk '{print $1}')"
got_size="$(wc -c < "$TMP/$platform.jar" | tr -d ' ')"
final_type="$(awk '
BEGIN { IGNORECASE = 1 }
/^content-type:/ {
sub(/\r$/, "")
sub(/^[^:]*:[[:space:]]*/, "")
type = $0
}
END { print type }
' "$TMP/$platform.headers")"
magic="$(od -An -tx1 -N4 "$TMP/$platform.jar" | tr -d ' \n')"
if [ "$got_sha256" != "$want_sha256" ]; then
echo "::error::Hangar's $platform download differs from the released jar."
echo "::error::sha256 built $want_sha256 / downloaded $got_sha256"
exit 1
fi
if [ "$got_size" != "$asset_size" ] || [ "$got_size" != "$(wc -c < "$jar" | tr -d ' ')" ]; then
echo "::error::Hangar's $platform download has the wrong size."
echo "::error::downloaded $got_size / GitHub API $asset_size / built $(wc -c < "$jar")"
exit 1
fi
case "$final_type" in
application/java-archive|application/octet-stream) ;;
*)
echo "::error::Hangar's $platform final content type is ${final_type:-<absent>}."
echo "::error::GitHub asset metadata reports ${asset_type:-<absent>}."
exit 1
;;
esac
if [ "$magic" != "504b0304" ]; then
echo "::error::Hangar's $platform download is not a JAR (magic ${magic:-<absent>})."
exit 1
fi
echo "OK: $platform serves $filename ($got_size bytes, $final_type,"
echo "OK: sha256 $want_sha256, ZIP magic 504b0304)."
}
verify_platform PAPER spigot/build/libs/connect-spigot.jar connect-spigot.jar
verify_platform VELOCITY velocity/build/libs/connect-velocity.jar connect-velocity.jar
verify_platform WATERFALL bungee/build/libs/connect-bungee.jar connect-bungee.jar
# Publish the same jars this run just built to the Modrinth listing.
#
# THE EVENT GATE IS THIS STEP'S SAFETY PROPERTY. Without it every push to
# main would publish a development build to a public listing. always()
# lets Modrinth run when Hangar alone fails, but the verified GitHub
# release outcome remains a hard prerequisite.
#
# The jars come from the RUNNER's build output, never from the release.
# Reading them back from the release would couple Modrinth publishing to
# the release having landed correctly - the exact failure the step above
# exists to catch - so the two stay independent.
- name: Publish to Modrinth
if: >-
always() &&
(github.event_name == 'release' || github.event_name == 'workflow_dispatch') &&
steps.verify_release_assets.outcome == 'success'
env:
# Passed as an environment variable, never interpolated into the
# script body: a ${{ secrets.* }} expression inside run: is expanded
# into the shell command itself, where a `set -x`, an error trace or
# a crash dump can print it.
MODRINTH_TOKEN: ${{ secrets.MODRINTH_TOKEN }}
# The immutable project id, not the slug "minekube-connect". A slug
# can be released and re-registered by someone else; publishing by
# slug would then upload our jars into a stranger's project without
# any error. An id that stops resolving 404s loudly instead.
MODRINTH_PROJECT_ID: PuSyuNRf
RELEASE_TAG: ${{ steps.release-tag.outputs.tag }}
run: |
set -euo pipefail
if [ -z "${MODRINTH_TOKEN:-}" ]; then
echo "::error::MODRINTH_TOKEN is empty; refusing to skip publishing silently."
exit 1
fi
if [ -z "${RELEASE_TAG:-}" ]; then
echo "::error::Release tag is empty; cannot derive Modrinth version numbers."
exit 1
fi
API="https://api.modrinth.com/v2"
UA="minekube/connect-java release workflow (+https://github.com/minekube/connect-java)"
TMP="$(mktemp -d)"
trap 'rm -rf "$TMP"' EXIT
# The token reaches curl through a config read from stdin, so it
# never appears in the process argument list and is never written to
# the runner's disk.
api() {
local out="$1"
shift
printf 'header = "Authorization: %s"\n' "$MODRINTH_TOKEN" \
| curl -sS -K - -A "$UA" -o "$out" -w '%{http_code}' "$@"
}
# Minecraft versions are resolved from Modrinth's own tag list at
# publish time and never hard-coded. A baked-in list stops matching
# searches the day Mojang ships a release, which is precisely the
# staleness this listing exists to avoid - and it fails invisibly,
# because the listing keeps working for everyone already on an old
# version. The floor is read from the plugin descriptor this build
# ships rather than restated here, so it cannot drift from the jar.
API_FLOOR="$(awk '/^api-version:/ {print $2; exit}' spigot/src/main/resources/plugin.yml)"
if [ -z "$API_FLOOR" ]; then
echo "::error::Could not read api-version from spigot/src/main/resources/plugin.yml."
exit 1
fi
curl -sS -A "$UA" "$API/tag/game_version" -o "$TMP/game_versions.json"
GAME_VERSIONS="$(jq -c --arg floor "$API_FLOOR" '
[.[] | select(.version_type == "release") | .version]
| (index($floor)) as $i
| if $i == null then
error("declared api-version \($floor) is not a Modrinth release version")
else .[0:$i + 1] end
' "$TMP/game_versions.json")"
echo "Declaring $(echo "$GAME_VERSIONS" | jq 'length') Minecraft releases, floor $API_FLOOR."
# A tag carrying a pre-release suffix is not a stable release, and
# labelling one "release" on the listing tells operators the opposite.
case "$RELEASE_TAG" in
*-*) CHANNEL="beta" ;;
*) CHANNEL="release" ;;
esac
CHANGELOG="Release notes: $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/releases/tag/$RELEASE_TAG"
# Best-effort inventory of what the listing already holds. It needs
# read scopes the publishing token may not carry, so a refusal is
# reported and tolerated rather than treated as fatal; a duplicate
# upload is then rejected by Modrinth itself.
HAVE_INVENTORY=0
INVENTORY_CODE="$(api "$TMP/existing.json" "$API/project/$MODRINTH_PROJECT_ID/version")"
case "$INVENTORY_CODE" in
200)
HAVE_INVENTORY=1
;;
401|403)
echo "::warning::Cannot list existing Modrinth versions (HTTP $INVENTORY_CODE);" \
"MODRINTH_TOKEN lacks PROJECT_READ+VERSION_READ. Relying on Modrinth to" \
"reject a duplicate upload."
;;
*)
echo "::error::Unexpected HTTP $INVENTORY_CODE listing versions of project $MODRINTH_PROJECT_ID."
cat "$TMP/existing.json" || true
exit 1
;;
esac
# One Modrinth version per platform jar, never one version carrying
# all three. Modrinth runs every validator whose loaders intersect
# the declared loaders against every file in the version, so a single
# version declaring velocity + bungeecord + paper is rejected: the
# velocity jar has no plugin.yml and the spigot jar has no
# velocity-plugin.json.
# (labrinth, apps/labrinth/src/validate/plugin.rs)
publish_platform() {
local platform="$1" jar="$2" loaders="$3" label="$4"
local number="$RELEASE_TAG+$platform"
local code version_id want_sha1 want_sha512 got_sha1 got_sha512 filename
if [ ! -f "$jar" ]; then
echo "::error::$jar was not produced by this build; nothing to publish."
exit 1
fi
if [ "$HAVE_INVENTORY" = "1" ] \
&& jq -e --arg n "$number" 'any(.[]; .version_number == $n)' "$TMP/existing.json" >/dev/null; then
# Re-dispatching this workflow to repair a release must not
# create a second copy of a version the listing already carries.
echo "Modrinth already carries $number; leaving it untouched."
return 0
fi
filename="$(basename "$jar")"
want_sha1="$(sha1sum "$jar" | awk '{print $1}')"
want_sha512="$(sha512sum "$jar" | awk '{print $1}')"
jq -n \
--arg project "$MODRINTH_PROJECT_ID" \
--arg number "$number" \
--arg title "$RELEASE_TAG ($label)" \
--arg changelog "$CHANGELOG" \
--arg channel "$CHANNEL" \
--argjson game_versions "$GAME_VERSIONS" \
--argjson loaders "$loaders" \
'{
project_id: $project,
file_parts: ["file"],
primary_file: "file",
version_number: $number,
name: $title,
changelog: $changelog,
dependencies: [],
game_versions: $game_versions,
loaders: $loaders,
version_type: $channel,
status: "listed",
featured: false,
environment: "server_only"
}' > "$TMP/data.json"
code="$(api "$TMP/created.json" -X POST "$API/version" \
-F "data=@$TMP/data.json;type=application/json" \
-F "file=@$jar;type=application/java-archive")"
if [ "$code" = "401" ] || [ "$code" = "403" ]; then
echo "::error::MODRINTH_TOKEN was refused (HTTP $code) creating version $number."
echo "::error::Creating a version requires the VERSION_CREATE scope."
cat "$TMP/created.json" || true
exit 1
fi
if [ "$code" != "200" ]; then
echo "::error::Modrinth rejected version $number (HTTP $code)."
cat "$TMP/created.json" || true
exit 1
fi
version_id="$(jq -r '.id' "$TMP/created.json")"
# The create response is the API describing its own request, which
# is the same "trust the run, not the artifact" mistake the release
# verification above exists to avoid. Read the stored version back
# and assert on the digests Modrinth computed from the bytes it
# actually holds. Size is not enough: two different jars can share
# a size and cannot share a digest.
code="$(api "$TMP/stored.json" "$API/version/$version_id")"
if [ "$code" = "401" ] || [ "$code" = "403" ]; then
echo "::error::MODRINTH_TOKEN was refused (HTTP $code) reading version $number back."
echo "::error::Reading a version back requires the VERSION_READ scope."
exit 1
fi
if [ "$code" != "200" ]; then
echo "::error::Could not read version $number back from Modrinth (HTTP $code);"
echo "::error::the upload cannot be confirmed to have stored our jar."
exit 1
fi
got_sha1="$(jq -r --arg f "$filename" \
'first(.files[] | select(.filename == $f) | .hashes.sha1) // ""' "$TMP/stored.json")"
got_sha512="$(jq -r --arg f "$filename" \
'first(.files[] | select(.filename == $f) | .hashes.sha512) // ""' "$TMP/stored.json")"
if [ "$got_sha1" != "$want_sha1" ] || [ "$got_sha512" != "$want_sha512" ]; then
echo "::error::Modrinth is serving different bytes than this build produced for $filename."
echo "::error::sha1 built $want_sha1 / stored ${got_sha1:-<absent>}"
echo "::error::sha512 built $want_sha512 / stored ${got_sha512:-<absent>}"
exit 1
fi
echo "OK: $number published as $version_id; $filename matches on sha1 and sha512."
}
publish_platform velocity velocity/build/libs/connect-velocity.jar '["velocity"]' "Velocity"
publish_platform spigot spigot/build/libs/connect-spigot.jar '["paper","spigot","bukkit"]' "Spigot"
publish_platform bungee bungee/build/libs/connect-bungee.jar '["bungeecord"]' "BungeeCord"