From a20462f2cb1756ff9046176509ddf0df0cd9aaa2 Mon Sep 17 00:00:00 2001 From: JohnBraham Date: Sun, 4 Oct 2026 22:49:13 +0100 Subject: [PATCH] Let the release action support all Minecraft targets --- .github/workflows/deploy-release.yml | 67 ++-- README.md | 1 + docs/releasing.md | 53 ++++ gradle/README.md | 3 + gradle/verification/workflows.gradle | 13 +- .../release/ReleaseDispatcherTest.java | 287 ++++++++++++++++++ 6 files changed, 397 insertions(+), 27 deletions(-) create mode 100644 docs/releasing.md create mode 100644 src/test/java/com/mcmoddev/basemetals/release/ReleaseDispatcherTest.java diff --git a/.github/workflows/deploy-release.yml b/.github/workflows/deploy-release.yml index 8d6f43eb..eb481494 100644 --- a/.github/workflows/deploy-release.yml +++ b/.github/workflows/deploy-release.yml @@ -143,8 +143,10 @@ jobs: java_toolchain_version="$(value java_toolchain_version)" java_setup_version="$(value java_setup_version)" gradle_java_version="$(value gradle_java_version)" + gradle_java_setup_version="$(value gradle_java_setup_version)" curseforge_project_id="$(value curseforge_project_id)" cf_requirements="$(value cf_requirements)" + orespawn_project_id="$(value orespawn_curse_project_id)" for required in release_version minecraft_version loader_name loader_code \ java_version gradle_java_version curseforge_project_id; do @@ -237,28 +239,30 @@ jobs: echo "Requested version $REQUESTED_VERSION does not match ${{ steps.route.outputs.target_branch }} mod_version $release_version" >&2 exit 1 fi - case "${{ steps.route.outputs.target_branch }}" in - master-1.12) - gradle_java_setup_version=17.0.1+12 - if [[ "$minecraft_version" != '1.12.2' || "$java_toolchain_version" != '8.0.502+7' \ - || "$gradle_java_version" != '17' ]]; then - echo "master-1.12 has an unexpected target/toolchain contract" >&2 - exit 1 - fi - ;; - master-1.18) + if [[ -z "$gradle_java_setup_version" ]]; then + if [[ "$install_gradle_java" == false ]]; then gradle_java_setup_version="$java_setup_version" - if [[ "$minecraft_version" != '1.18.2' || "$java_toolchain_version" != '17.0.1+12' \ - || "$gradle_java_version" != '17' ]]; then - echo "master-1.18 has an unexpected target/toolchain contract" >&2 - exit 1 - fi - ;; - *) - echo "This dispatcher supports Base Metals master-1.12 and master-1.18" >&2 - exit 1 - ;; - esac + elif [[ "$gradle_java_version" == 17 ]]; then + # Legacy targets keep the same pinned Gradle JDK as their CI builds. + gradle_java_setup_version=17.0.1+12 + else + gradle_java_setup_version="$gradle_java_version" + fi + fi + if [[ "$(java_major "$java_toolchain_version")" != "$java_version" \ + || "$(java_major "$gradle_java_setup_version")" != "$gradle_java_version" ]]; then + echo "Java setup selectors do not match the declared game and Gradle Java versions" >&2 + exit 1 + fi + + if [[ -n "$orespawn_project_id" && "$orespawn_project_id" != 245586 ]]; then + echo "Unexpected OreSpawn CurseForge project $orespawn_project_id" >&2 + exit 1 + fi + if [[ -z "$cf_requirements" && "$orespawn_project_id" == 245586 ]]; then + # MMDLib-free ports declare their required OreSpawn dependency directly. + cf_requirements=mmd-orespawn + fi curseforge_dependencies=() IFS=',' read -ra requirement_slugs <<<"$cf_requirements" @@ -270,8 +274,8 @@ jobs: *) echo "Unsupported required CurseForge relation $slug" >&2; exit 1 ;; esac done - if (( ${#curseforge_dependencies[@]} == 0 )); then - echo "No required CurseForge relations were declared" >&2 + if [[ " ${curseforge_dependencies[*]} " != *' 245586(required) '* ]]; then + echo "The required OreSpawn CurseForge relation was not declared" >&2 exit 1 fi @@ -405,6 +409,8 @@ jobs: echo "property=$property" >> "$GITHUB_OUTPUT" - name: Build, test, and audit once + env: + LOADER_NAME: ${{ needs.preflight.outputs.loader_name }} run: | set -euo pipefail chmod +x ./gradlew @@ -414,9 +420,18 @@ jobs: -Dorg.gradle.java.installations.auto-download=false ) dependency_argument="-P${{ steps.dependencies.outputs.property }}=${{ steps.dependencies.outputs.repository }}" - ./gradlew clean check build javadoc verifyReleaseArtifacts writeReleaseChecksums \ - "$dependency_argument" \ - --no-daemon --stacktrace "${java_args[@]}" + if [[ "$LOADER_NAME" == neoforge ]]; then + # NeoGradle prepares inputs in build/tmp during configuration. Clean first. + ./gradlew clean "$dependency_argument" \ + --no-daemon --stacktrace --max-workers=2 "${java_args[@]}" + ./gradlew check build javadoc verifyReleaseArtifacts writeReleaseChecksums \ + "$dependency_argument" \ + --no-daemon --stacktrace --max-workers=2 "${java_args[@]}" + else + ./gradlew clean check build javadoc verifyReleaseArtifacts writeReleaseChecksums \ + "$dependency_argument" \ + --no-daemon --stacktrace "${java_args[@]}" + fi - name: Stage the immutable release artifact id: artifact diff --git a/README.md b/README.md index d40eaced..76fe9662 100644 --- a/README.md +++ b/README.md @@ -126,6 +126,7 @@ toolchain and dependency versions are recorded in `gradle.properties`. `prepareEclipse` creates client and server launches with processed resources and keeps optional compile-only mods and test code out of normal launches. The [build-script guide](gradle/README.md) explains the supporting scripts. +The [release guide](docs/releasing.md) covers publication from the MMD Actions tab. Release artifacts use the Maven coordinates `zone.moddev.mc.basemetals:BaseMetals:2.6.0.112021`. Java packages remain under diff --git a/docs/releasing.md b/docs/releasing.md new file mode 100644 index 00000000..844e497c --- /dev/null +++ b/docs/releasing.md @@ -0,0 +1,53 @@ +# Releasing Base Metals + +Run **Release Base Metals** in the MMD repository's Actions tab. The workflow +lives on the default branch, but builds the selected Minecraft branch rather +than the default branch's mod. + +Enter the full four-part Base Metals version, choose the CurseForge release +level, and confirm publication. For example, `3.0.1.113021` selects Minecraft +1.13.2 with Forge and releases `master-1.13.2`. + +## Branches and version numbers + +The final version component encodes Minecraft's major version, two-digit minor +version, two-digit patch version, and a loader digit: `1` for Forge or `2` for +NeoForge. The dispatcher looks for `master-..` first, then +`master-.`. NeoForge branches have the suffix `-neo`. + +This follows OreSpawn's branch convention, including newer `26.x` versions. +There is no list of allowed Minecraft versions to update when adding a port. +The selected branch must exist and its metadata must match the requested +release. Old branches without the modern build and release checks cannot be +released through this workflow. + +## Preparing a target branch + +Each port needs: + +- Matching `mod_version`, `minecraft_version`, `loader_name`, and `loader_code` + in `gradle.properties`, with `curseforge_project_id=240967`. +- `java_version` for Minecraft, `java_toolchain_version` for compilation, and + `gradle_java_version` for Gradle. `java_setup_version` can specify the hosted + JDK download selector. `gradle_java_setup_version` can pin a separate Gradle + JDK; legacy Java 8 and 16 ports default to Temurin `17.0.1+12` for Gradle. +- An exact, checksum-verified OreSpawn dependency and either + `gradle/stage-orespawn-release.sh` or the legacy dependency-staging script. +- `check`, `build`, `javadoc`, `verifyReleaseArtifacts`, `writeReleaseChecksums`, + and the prepared-artifact Maven publication used by the existing ports. +- A successful **Build, test, and audit** check on the exact release commit. + +Required CurseForge dependencies come from `cf_requirements`. MMDLib-free ports +may instead declare `orespawn_curse_project_id=245586`, which makes OreSpawn +required without adding MMDLib. The 1.12 branch still requires both mods. + +## Publication + +The workflow builds and tests once, then records checksums for the main, +sources, and Javadoc jars. It publishes those same files to MMD Maven, +CurseForge, and finally GitHub Releases. An existing tag must point to the +validated commit; the workflow never moves it to another commit. + +Publication requires `MAVEN_UPLOAD_URL`, `MAVEN_UPLOAD_USERNAME`, +`MAVEN_UPLOAD_PASSWORD`, and `CURSEFORGE_TOKEN` in the MMD repository. Forks can +run CI but cannot publish through this dispatcher. diff --git a/gradle/README.md b/gradle/README.md index 144935ed..891d04f9 100644 --- a/gradle/README.md +++ b/gradle/README.md @@ -30,6 +30,9 @@ of that main path: - `ide/eclipse.gradle` generates and verifies isolated Eclipse launches. - `verification/workflows.gradle` checks the pinned GitHub Actions contracts. +The [release guide](../docs/releasing.md) explains how the default-branch +dispatcher selects and publishes a Minecraft target. + Scripts share settings through small immutable maps rather than relying on variables from another script's scope. diff --git a/gradle/verification/workflows.gradle b/gradle/verification/workflows.gradle index b67c7838..ed5f9f10 100644 --- a/gradle/verification/workflows.gradle +++ b/gradle/verification/workflows.gradle @@ -24,11 +24,14 @@ tasks.register('verifyWorkflowContracts') { 'deploy-release.yml', '**Nothing has been published.**' ], 'deploy-release.yml': [ - 'master-1.12)', 'master-1.18)', + 'master-$mc_major.$mc_minor.$mc_patch$loader_suffix', + 'master-$mc_major.$mc_minor$loader_suffix', 'stage-legacy-dependencies.sh', 'stage-orespawn-release.sh', "mmdlib) curseforge_dependencies+=('261744(required)')", + 'cf_requirements=mmd-orespawn', 'gradle_java_setup_version=17.0.1+12', 'java-version: ${{ needs.preflight.outputs.gradle_java_setup_version }}', + 'if [[ "$LOADER_NAME" == neoforge ]]', 'confirm_live_publication' ] ] @@ -38,6 +41,10 @@ tasks.register('verifyWorkflowContracts') { throw new GradleException("Missing workflow ${name}") } String text = workflow.getText('UTF-8').replace('\r\n', '\n') + if (name == 'deploy-release.yml' + && text.contains('This dispatcher supports Base Metals master-1.12 and master-1.18')) { + throw new GradleException('The release dispatcher must not limit releases to two branches') + } List missing = fragments.findAll { !text.contains(it) } if (!missing.isEmpty()) { throw new GradleException("${name} is missing contract fragments ${missing}") @@ -55,3 +62,7 @@ tasks.register('verifyWorkflowContracts') { tasks.named('check') { dependsOn tasks.named('verifyWorkflowContracts') } + +tasks.named('test') { + inputs.file('.github/workflows/deploy-release.yml') +} diff --git a/src/test/java/com/mcmoddev/basemetals/release/ReleaseDispatcherTest.java b/src/test/java/com/mcmoddev/basemetals/release/ReleaseDispatcherTest.java new file mode 100644 index 00000000..fddb0160 --- /dev/null +++ b/src/test/java/com/mcmoddev/basemetals/release/ReleaseDispatcherTest.java @@ -0,0 +1,287 @@ +package com.mcmoddev.basemetals.release; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@Timeout(120) +class ReleaseDispatcherTest { + + @TempDir + Path directory; + + @Test + void releasesJava8PortsWithoutAddingMmdlibToTheirDependencies() throws Exception { + final Map properties = metadata("1.13.2", "forge", "8.0.502+7"); + final Result result = preflight("master-1.13.2", properties); + + assertEquals(0, result.exitCode, result.log); + assertTrue(result.outputs.contains("gradle_java_setup_version=17.0.1+12")); + assertTrue(result.outputs.contains("install_gradle_java=true")); + assertTrue(result.outputs.contains("245586(required)")); + assertFalse(result.outputs.contains("261744(required)")); + } + + @Test + void keepsBothRequiredDependenciesForTheLegacyRelease() throws Exception { + final Map properties = metadata("1.12.2", "forge", "8.0.502+7"); + properties.remove("orespawn_curse_project_id"); + properties.put("orespawn4_curse_project_id", "245586"); + properties.put("cf_requirements", "mmd-orespawn,mmdlib"); + + final Result result = preflight("master-1.12", properties); + + assertEquals(0, result.exitCode, result.log); + assertTrue(result.outputs.contains("245586(required)\n261744(required)")); + } + + @Test + void acceptsOreSpawnsForgeAndNeoForgeBranchNames() throws Exception { + final String[][] targets = { + {"master-1.8.9", "1.8.9", "forge", "8.0.502+7"}, + {"master-1.9", "1.9", "forge", "8.0.502+7"}, + {"master-1.9.4", "1.9.4", "forge", "8.0.502+7"}, + {"master-1.10", "1.10.2", "forge", "8.0.502+7"}, + {"master-1.11", "1.11", "forge", "8.0.502+7"}, + {"master-1.11.2", "1.11.2", "forge", "8.0.502+7"}, + {"master-1.12.2", "1.12.2", "forge", "8.0.502+7"}, + {"master-1.14.4", "1.14.4", "forge", "8.0.502+7"}, + {"master-1.15.2", "1.15.2", "forge", "8.0.502+7"}, + {"master-1.16.5", "1.16.5", "forge", "8.0.502+7"}, + {"master-1.17.1", "1.17.1", "forge", "16.0.2+7"}, + {"master-1.18", "1.18.2", "forge", "17.0.1+12"}, + {"master-1.19", "1.19.4", "forge", "17.0.1+12"}, + {"master-1.20.1", "1.20.1", "forge", "17.0.1+12"}, + {"master-1.20-neo", "1.20.1", "neoforge", "17.0.1+12"}, + {"master-1.20.6", "1.20.6", "forge", "21.0.8+9"}, + {"master-1.20.6-neo", "1.20.6", "neoforge", "21.0.8+9"}, + {"master-1.21.1", "1.21.1", "forge", "21.0.8+9"}, + {"master-1.21.1-neo", "1.21.1", "neoforge", "21.0.8+9"}, + {"master-1.21.11", "1.21.11", "forge", "21.0.8+9"}, + {"master-1.21.11-neo", "1.21.11", "neoforge", "21.0.8+9"}, + {"master-26.1.2", "26.1.2", "forge", "25.0.3+9"}, + {"master-26.1.2-neo", "26.1.2", "neoforge", "25.0.3+9"}, + {"master-26.2", "26.2", "forge", "25.0.3+9"}, + {"master-26.2-neo", "26.2", "neoforge", "25.0.3+9"}, + {"master-26.3", "26.3", "forge", "25.0.3+9"}, + {"master-26.3-neo", "26.3", "neoforge", "25.0.3+9"} + }; + + for (final String[] target : targets) { + final Result result = preflight(target[0], metadata(target[1], target[2], target[3])); + + assertEquals(0, result.exitCode, target[0] + "\n" + result.log); + assertTrue(result.outputs.contains("target_branch=" + target[0])); + assertTrue(result.outputs.contains("loader_name=" + target[2])); + } + } + + @Test + void rejectsWrongVersionsProjectsLoadersAndJavaSelectors() throws Exception { + final String[][] invalid = { + {"mod_version", "4.1.0.113021"}, + {"curseforge_project_id", "245586"}, + {"loader_code", "2"}, + {"java_setup_version", "17.0.1+12"}, + {"gradle_java_setup_version", "8.0.502+7"}, + {"orespawn_curse_project_id", "1"}, + {"cf_requirements", "mmdlib"}, + {"cf_requirements", "unknown-mod"} + }; + + for (final String[] change : invalid) { + final Map properties = metadata("1.13.2", "forge", "8.0.502+7"); + properties.put(change[0], change[1]); + + final Result result = preflight("master-1.13.2", properties); + + assertTrue(result.exitCode != 0, change[0] + " was accepted\n" + result.log); + } + } + + @Test + void requiresASuccessfulPriorBuild() throws Exception { + final Map environment = new LinkedHashMap<>(); + environment.put("TEST_CI_COUNT", "0"); + + final Result result = preflight("master-1.13.2", + metadata("1.13.2", "forge", "8.0.502+7"), environment); + + assertTrue(result.exitCode != 0, result.log); + assertTrue(result.log.contains("no successful Build, test, and audit check"), result.log); + } + + @Test + void refusesToReleaseFromAFork() throws Exception { + final Map environment = new LinkedHashMap<>(); + environment.put("GITHUB_REPOSITORY", "SkyBlade1978/BaseMetals"); + + final Result result = preflight("master-1.13.2", + metadata("1.13.2", "forge", "8.0.502+7"), environment); + + assertTrue(result.exitCode != 0, result.log); + assertTrue(result.log.contains("Releases can only run in"), result.log); + } + + @Test + void cleansNeoForgeSeparatelyAndKeepsTheForgeBuildInOneInvocation() throws Exception { + final String gradleStub = "#!/usr/bin/env bash\nprintf '%s\\n' \"$*\" >> invocations.txt\n"; + Files.write(directory.resolve("gradlew"), gradleStub.getBytes(StandardCharsets.UTF_8)); + final String script = runBlock("Build, test, and audit once") + .replace("${{ steps.java-paths.outputs.paths }}", "/jdk") + .replace("${{ steps.dependencies.outputs.property }}", "orespawnVerificationRepository") + .replace("${{ steps.dependencies.outputs.repository }}", "/mirror"); + + for (final String loader : new String[] {"forge", "neoforge"}) { + Files.deleteIfExists(directory.resolve("invocations.txt")); + final Map environment = new LinkedHashMap<>(); + environment.put("LOADER_NAME", loader); + + final Result result = execute(script, environment); + final String[] invocations = read(directory.resolve("invocations.txt")).trim().split("\n"); + + assertEquals(0, result.exitCode, result.log); + assertEquals("neoforge".equals(loader) ? 2 : 1, invocations.length); + assertTrue(invocations[0].startsWith("clean ")); + assertTrue(invocations[invocations.length - 1].contains("check build javadoc verifyReleaseArtifacts")); + for (final String invocation : invocations) { + assertTrue(invocation.contains("-PorespawnVerificationRepository=/mirror")); + } + } + } + + private Result preflight(final String branch, final Map properties) throws Exception { + return preflight(branch, properties, new LinkedHashMap<>()); + } + + private Result preflight(final String branch, final Map properties, + final Map overrides) throws Exception { + final StringBuilder metadata = new StringBuilder(); + properties.forEach((key, value) -> metadata.append(key).append('=').append(value).append('\n')); + Files.write(directory.resolve("gradle.properties"), metadata.toString().getBytes(StandardCharsets.UTF_8)); + + // Run the workflow's Bash, but never contact GitHub or create a real tag. + final String stubs = "gh() {\n" + + " case \"$1 $2\" in\n" + + " \"api repos/$GITHUB_REPOSITORY/branches/$TEST_BRANCH\") return 0 ;;\n" + + " \"api repos/$GITHUB_REPOSITORY/commits/\"*\"/check-runs?per_page=100\") echo \"$TEST_CI_COUNT\" ;;\n" + + " \"api repos/$GITHUB_REPOSITORY/branches/\"*|\"api repos/$GITHUB_REPOSITORY/git/ref/tags/\"*) return 1 ;;\n" + + " *) echo \"Unexpected GitHub call: $*\" >&2; return 99 ;;\n" + + " esac\n}\n" + + "git() { [[ \"$*\" == 'rev-parse HEAD' ]] || return 99; echo 0123456789012345678901234567890123456789; }\n"; + final String script = stubs + runBlock("Resolve target branch from version") + "\n" + + runBlock("Validate version, target, tag, and prior CI") + .replace("${{ steps.route.outputs.target_branch }}", branch); + final Map environment = new LinkedHashMap<>(); + environment.put("GITHUB_REPOSITORY", "MinecraftModDevelopmentMods/BaseMetals"); + environment.put("RELEASE_VERSION", version(properties.get("minecraft_version"), + properties.get("loader_name"))); + environment.put("REQUESTED_VERSION", environment.get("RELEASE_VERSION")); + environment.put("TEST_BRANCH", branch); + environment.put("TEST_CI_COUNT", "1"); + environment.putAll(overrides); + + return execute(script, environment); + } + + private Result execute(final String script, final Map environment) throws Exception { + final Path scriptFile = directory.resolve("preflight.sh"); + final Path outputFile = directory.resolve("outputs.txt"); + final Path logFile = directory.resolve("log.txt"); + Files.write(scriptFile, script.getBytes(StandardCharsets.UTF_8)); + Files.deleteIfExists(outputFile); + + final ProcessBuilder builder = new ProcessBuilder(bash(), scriptFile.toString().replace('\\', '/')); + builder.directory(directory.toFile()); + builder.redirectErrorStream(true); + builder.redirectOutput(logFile.toFile()); + builder.environment().put("GITHUB_OUTPUT", outputFile.toString().replace('\\', '/')); + builder.environment().putAll(environment); + + final Process process = builder.start(); + if (!process.waitFor(15, TimeUnit.SECONDS)) { + process.destroyForcibly(); + throw new AssertionError("Release preflight timed out"); + } + + return new Result(process.exitValue(), read(logFile), Files.exists(outputFile) ? read(outputFile) : ""); + } + + private static Map metadata(final String minecraft, final String loader, final String java) { + final Map properties = new LinkedHashMap<>(); + final String javaMajor = java.split("\\.")[0]; + properties.put("mod_version", version(minecraft, loader)); + properties.put("minecraft_version", minecraft); + properties.put("loader_name", loader); + properties.put("loader_code", "neoforge".equals(loader) ? "2" : "1"); + properties.put("java_version", javaMajor); + properties.put("java_toolchain_version", java); + properties.put("gradle_java_version", Integer.parseInt(javaMajor) < 17 ? "17" : javaMajor); + properties.put("curseforge_project_id", "240967"); + properties.put("orespawn_curse_project_id", "245586"); + return properties; + } + + private static String version(final String minecraft, final String loader) { + final String[] parts = minecraft.split("\\."); + return String.format("3.0.1.%s%02d%02d%s", parts[0], Integer.parseInt(parts[1]), + parts.length > 2 ? Integer.parseInt(parts[2]) : 0, "neoforge".equals(loader) ? "2" : "1"); + } + + private static String runBlock(final String step) throws IOException { + final String workflow = read(Paths.get(".github/workflows/deploy-release.yml")); + final int start = workflow.indexOf(" - name: " + step + "\n"); + assertTrue(start >= 0, "Missing workflow step " + step); + final int body = workflow.indexOf(" run: |\n", start) + " run: |\n".length(); + final StringBuilder script = new StringBuilder(); + + for (final String line : workflow.substring(body).split("\n", -1)) { + if (!line.isEmpty() && !line.startsWith(" ")) { + break; + } + script.append(line.isEmpty() ? "" : line.substring(10)).append('\n'); + } + + assertFalse(script.toString().trim().isEmpty(), "Empty workflow step " + step); + return script.toString(); + } + + private static String bash() { + if (System.getProperty("os.name").startsWith("Windows")) { + final Path gitBash = Paths.get(System.getenv("ProgramFiles"), "Git", "bin", "bash.exe"); + if (Files.isRegularFile(gitBash)) { + return gitBash.toString(); + } + } + return "bash"; + } + + private static String read(final Path path) throws IOException { + return new String(Files.readAllBytes(path), StandardCharsets.UTF_8).replace("\r\n", "\n"); + } + + private static final class Result { + private final int exitCode; + private final String log; + private final String outputs; + + private Result(final int exitCode, final String log, final String outputs) { + this.exitCode = exitCode; + this.log = log; + this.outputs = outputs; + } + } +}