fix(build): make release publishing repeatable (#134) #144
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: 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 | |
| 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" |