forked from GeyserMC/Floodgate
-
Notifications
You must be signed in to change notification settings - Fork 6
878 lines (801 loc) · 39.9 KB
/
Copy pathrelease.yml
File metadata and controls
878 lines (801 loc) · 39.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
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"