From cbe5f4327e5dd04fc33028b4e3781b94a35a635f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Robin=20Br=C3=A4mer?= <22003767+robinbraemer@users.noreply.github.com> Date: Sat, 8 Aug 2026 10:16:02 +0200 Subject: [PATCH] fix(build): make release publishing repeatable --- .github/workflows/release.yml | 23 ++++++----- build.gradle.kts | 9 +++++ .../release/ReleaseAssetVerificationTest.java | 38 +++++++++++++++++++ .../release/ReleaseModrinthPublishTest.java | 25 +++++------- 4 files changed, 70 insertions(+), 25 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8d8b59fd2..eb92df03a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -68,6 +68,9 @@ jobs: 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 @@ -82,6 +85,7 @@ jobs: 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 @@ -113,6 +117,7 @@ jobs: tag_name: latest-prerelease name: "Latest Pre-Release (${{ steps.version.outputs.version }})" prerelease: true + overwrite_files: true files: | prerelease/*.jar LICENSE @@ -137,6 +142,7 @@ jobs: # 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 }} @@ -652,21 +658,20 @@ jobs: # Publish the same jars this run just built to the Modrinth listing. # - # THE EVENT GATE IS THIS STEP'S SAFETY PROPERTY. It carries the identical - # condition as "Upload Release Artifacts" and "Update Latest Release" - # above. Without it every push to main would publish a development build - # to a public listing, and that would not fail loudly: the run stays - # green, the listing quietly fills with pre-release versions, and the - # first report comes from a user. ReleaseModrinthPublishTest pins the - # condition to be byte-identical to the two upload steps' condition, so - # deleting or weakening it fails the build instead of failing silently. + # 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: github.event_name == 'release' || github.event_name == 'workflow_dispatch' + 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 diff --git a/build.gradle.kts b/build.gradle.kts index 498ec73e1..eee7ac993 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,3 +1,5 @@ +import org.gradle.api.tasks.bundling.AbstractArchiveTask + plugins { `java-library` id("connect.build-logic") @@ -9,6 +11,13 @@ allprojects { version = gitVersion() description = "Connects the server/proxy to the global Connect network to reach more players while also supporting online mode server, bungee or velocity mode. Visit https://minekube.com/connect" + + // A release retry must rebuild the exact same bytes. Otherwise an already-published + // marketplace version can no longer be verified against the GitHub release it references. + tasks.withType().configureEach { + isPreserveFileTimestamps = false + isReproducibleFileOrder = true + } } val deployProjects = setOf( diff --git a/core/src/test/java/com/minekube/connect/release/ReleaseAssetVerificationTest.java b/core/src/test/java/com/minekube/connect/release/ReleaseAssetVerificationTest.java index 29d1dd27a..d41b0d239 100644 --- a/core/src/test/java/com/minekube/connect/release/ReleaseAssetVerificationTest.java +++ b/core/src/test/java/com/minekube/connect/release/ReleaseAssetVerificationTest.java @@ -63,6 +63,7 @@ class ReleaseAssetVerificationTest { private static final Path WORKFLOW_PATH = Paths.get("..", ".github", "workflows", "release.yml"); + private static final Path BUILD_GRADLE_PATH = Paths.get("..", "build.gradle.kts"); private static final Path REPOSITORY_GIT_PATH = Paths.get("..", ".git"); @SuppressWarnings("unchecked") @@ -270,4 +271,41 @@ void releaseVerificationCoversEveryPublishedTarget() throws Exception { .matcher(script).find(), "guard does not iterate over its assigned release targets"); } + + /** + * A retry may refresh moving pointers, but it must never replace a versioned artifact after a + * marketplace has recorded that artifact's digest. + */ + @Test + @SuppressWarnings("unchecked") + void versionedReleaseAssetsAreImmutableButPointersAdvance() throws Exception { + List> steps = readBuildJobSteps(); + + Map versioned = (Map) + steps.get(stepIndex(steps, "Upload Release Artifacts")).get("with"); + Map latest = (Map) + steps.get(stepIndex(steps, "Update Latest Release")).get("with"); + Map prerelease = (Map) + steps.get(stepIndex(steps, "Update Pre-Release")).get("with"); + + assertEquals("false", String.valueOf(versioned.get("overwrite_files")), + "a retry can overwrite immutable versioned release assets"); + assertEquals("true", String.valueOf(latest.get("overwrite_files")), + "the latest pointer cannot advance to a new release"); + assertEquals("true", String.valueOf(prerelease.get("overwrite_files")), + "the latest-prerelease pointer cannot advance to a new build"); + } + + /** A rebuild of the same tag must produce byte-identical archives. */ + @Test + void gradleArchivesAreConfiguredForReproducibleBytes() throws Exception { + String build = Files.readString(BUILD_GRADLE_PATH); + + assertTrue(build.contains("tasks.withType().configureEach"), + "archive reproducibility is not applied to every project"); + assertTrue(build.contains("isPreserveFileTimestamps = false"), + "archive entries still preserve volatile build timestamps"); + assertTrue(build.contains("isReproducibleFileOrder = true"), + "archive entry order is not deterministic"); + } } diff --git a/core/src/test/java/com/minekube/connect/release/ReleaseModrinthPublishTest.java b/core/src/test/java/com/minekube/connect/release/ReleaseModrinthPublishTest.java index 6ff1d547a..e74d2f865 100644 --- a/core/src/test/java/com/minekube/connect/release/ReleaseModrinthPublishTest.java +++ b/core/src/test/java/com/minekube/connect/release/ReleaseModrinthPublishTest.java @@ -25,7 +25,6 @@ package com.minekube.connect.release; -import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assumptions.assumeTrue; @@ -54,15 +53,6 @@ class ReleaseModrinthPublishTest { private static final String MODRINTH_STEP = "Publish to Modrinth"; - /** - * The steps that already publish only on a real release. The Modrinth step must carry their - * condition exactly - not an equivalent-looking one - so there is a single event gate in this - * workflow rather than two that can drift apart. - */ - private static final List RELEASE_ONLY_STEPS = Arrays.asList( - "Upload Release Artifacts", - "Update Latest Release"); - private static final Path WORKFLOW_PATH = Paths.get("..", ".github", "workflows", "release.yml"); private static final Path REPOSITORY_GIT_PATH = Paths.get("..", ".git"); @@ -130,12 +120,15 @@ void modrinthPublishOnlyRunsForARealRelease() throws Exception { "\"" + MODRINTH_STEP + "\" has no event condition; every push to main would " + "publish a development build to the public Modrinth listing"); - for (String releaseOnly : RELEASE_ONLY_STEPS) { - int at = stepIndex(steps, releaseOnly); - assertTrue(at >= 0, "expected release-only step \"" + releaseOnly + "\""); - assertEquals(steps.get(at).get("if"), condition, - "\"" + MODRINTH_STEP + "\" does not carry the same event condition as \"" - + releaseOnly + "\"; the two gates can drift apart"); + String conditionText = (String) condition; + List required = Arrays.asList( + "always()", + "github.event_name == 'release'", + "github.event_name == 'workflow_dispatch'", + "steps.verify_release_assets.outcome == 'success'"); + for (String fragment : required) { + assertTrue(conditionText.contains(fragment), + "\"" + MODRINTH_STEP + "\" condition is missing \"" + fragment + "\""); } }