Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 14 additions & 9 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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 }}
Expand Down Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import org.gradle.api.tasks.bundling.AbstractArchiveTask

plugins {
`java-library`
id("connect.build-logic")
Expand All @@ -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<AbstractArchiveTask>().configureEach {
isPreserveFileTimestamps = false
isReproducibleFileOrder = true
}
}

val deployProjects = setOf(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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<Map<String, Object>> steps = readBuildJobSteps();

Map<String, Object> versioned = (Map<String, Object>)
steps.get(stepIndex(steps, "Upload Release Artifacts")).get("with");
Map<String, Object> latest = (Map<String, Object>)
steps.get(stepIndex(steps, "Update Latest Release")).get("with");
Map<String, Object> prerelease = (Map<String, Object>)
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<AbstractArchiveTask>().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");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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<String> 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");
Expand Down Expand Up @@ -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<String> 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 + "\"");
}
}

Expand Down
Loading