From 53cb5672466c3563fb996fc2dafd39e103523af7 Mon Sep 17 00:00:00 2001 From: JohnBraham Date: Tue, 29 Sep 2026 22:10:48 +0100 Subject: [PATCH] Tidy the 1.18 build scripts --- .gitattributes | 3 + build.gradle | 1227 +------------------ gradle/README.md | 18 + gradle/dependencies.gradle | 81 ++ gradle/ide/eclipse.gradle | 251 ++++ gradle/release/artifacts.gradle | 377 ++++++ gradle/release/publishing.gradle | 74 ++ gradle/verification/packaged-runtime.gradle | 243 ++++ gradle/verification/support.gradle | 133 ++ 9 files changed, 1194 insertions(+), 1213 deletions(-) create mode 100644 gradle/README.md create mode 100644 gradle/dependencies.gradle create mode 100644 gradle/ide/eclipse.gradle create mode 100644 gradle/release/artifacts.gradle create mode 100644 gradle/release/publishing.gradle create mode 100644 gradle/verification/packaged-runtime.gradle create mode 100644 gradle/verification/support.gradle diff --git a/.gitattributes b/.gitattributes index df36b4f1..de466628 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,7 +1,10 @@ # Disable autocrlf on generated files, they always generate with LF # Add any extra files or paths here to make git stop saying they # are changed when only line endings change. +.gitattributes text eol=lf src/generated/**/.cache/cache text eol=lf src/generated/**/*.json text eol=lf +*.gradle text eol=lf +*.md text eol=lf /gradlew text eol=lf /gradlew.bat text eol=lf diff --git a/build.gradle b/build.gradle index 84a29dad..1c0ba5a3 100644 --- a/build.gradle +++ b/build.gradle @@ -1,9 +1,3 @@ -import groovy.json.JsonSlurper -import groovy.xml.XmlParser -import groovy.xml.XmlSlurper -import java.security.MessageDigest -import java.util.zip.ZipFile - plugins { id 'java' id 'eclipse' @@ -13,9 +7,9 @@ plugins { id 'net.minecraftforge.gradle' version '7.0.34' } -def mavenizerCompatibilityJar = layout.projectDirectory.file( +ext.mavenizerCompatibilityJar = layout.projectDirectory.file( 'ci-fixtures/tools/minecraft-mavenizer-0.5.21-orespawn-1.18.jar') -def mavenizerCompatibilitySha256 = +ext.mavenizerCompatibilitySha256 = '28A6697BDF6D9500EC0AF9C1C949B9F3ED7DDFDA649CB5CAC4BEEA2E9567570A' // ForgeGradle 7's Mavenizer recompilation rejects a redundant accessor emitted @@ -66,11 +60,6 @@ java { withJavadocJar() } -def java17Launcher = javaToolchains.launcherFor { - languageVersion = JavaLanguageVersion.of(17) - vendor = JvmVendorSpec.ADOPTIUM -} - tasks.withType(JavaCompile).configureEach { javaCompiler = javaToolchains.compilerFor { languageVersion = JavaLanguageVersion.of(17) @@ -157,65 +146,19 @@ configurations { println "Java: ${System.getProperty 'java.version'}, JVM: ${System.getProperty 'java.vm.version'} (${System.getProperty 'java.vendor'}), Arch: ${System.getProperty 'os.arch'}" minecraft { - // The mappings can be changed at any time and must be in the following format. - // Channel: Version: - // official MCVersion Official field/method names from Mojang mapping files - // parchment YYYY.MM.DD-MCVersion Open community-sourced parameter names and javadocs layered on top of official - // - // You must be aware of the Mojang license when using the 'official' or 'parchment' mappings. - // See more information here: https://github.com/MinecraftForge/MCPConfig/blob/master/Mojang.md - // - // Parchment is an unofficial project maintained by ParchmentMC, separate from MinecraftForge - // Additional setup is needed to use their mappings: https://github.com/ParchmentMC/Parchment/wiki/Getting-Started - // - // Use non-default mappings at your own risk. They may not always work. - // Simply re-run your setup task after changing the mappings to update your workspace. + // Official mappings are part of the qualified 1.18.2 release identity. mappings channel: mapping_channel, version: mapping_version - // When true, this property will have all Eclipse/IntelliJ IDEA run configurations run the "prepareX" task for the given run configuration before launching the game. - // In most cases, it is not necessary to enable. - // enableEclipsePrepareRuns = true - // enableIdeaPrepareRuns = true - - // This property allows configuring Gradle's ProcessResources task(s) to run on IDE output locations before launching the game. - // It is REQUIRED to be set to true for this template to function. - // See https://docs.gradle.org/current/dsl/org.gradle.language.jvm.tasks.ProcessResources.html - // ForgeGradle 7 resource synchronization is configured below. - - // When true, this property will add the folder name of all declared run configurations to generated IDE run configurations. - // The folder name can be set on a run configuration using the "folderName" property. - // By default, the folder name of a run configuration is the name of the Gradle project containing it. - // generateRunFolders = true - - // This property enables access transformers for use in development. - // They will be applied to the Minecraft artifact. - // The access transformer file can be anywhere in the project. - // However, it must be at "META-INF/accesstransformer.cfg" in the final mod jar to be loaded by Forge. - // This default location is a best practice to automatically put the file in the right place in the final jar. - // See https://docs.minecraftforge.net/en/latest/advanced/accesstransformers/ for more information. - // accessTransformer = file('src/main/resources/META-INF/accesstransformer.cfg') - - // Default run configurations. - // These can be tweaked, removed, or duplicated as needed. runs { - // applies to all the run configs below configureEach { workingDir.convention layout.projectDirectory.dir('run') jvmArgs '--add-opens', 'java.base/java.lang.invoke=ALL-UNNAMED' - // Recommended logging data for a userdev environment - // The markers can be added/remove as needed separated by commas. - // "SCAN": For mods scan. - // "REGISTRIES": For firing of registry events. - // "REGISTRYDUMP": For getting the contents of all registries. + // Keep registry diagnostics available in development logs. systemProperty 'forge.logging.markers', 'REGISTRIES' - // Recommended logging level for the console - // You can set various levels here. - // Please read: https://stackoverflow.com/questions/2031163/when-to-use-the-different-log-levels systemProperty 'forge.logging.console.level', 'debug' - // Comma-separated list of namespaces to load gametests from. Empty = all namespaces. systemProperty 'forge.enabledGameTestNamespaces', mod_id if (providers.gradleProperty('orespawnBenchmarkMode').isPresent()) { @@ -252,7 +195,6 @@ minecraft { } client { - // Comma-separated list of namespaces to load gametests from. Empty = all namespaces. systemProperty 'forge.enabledGameTestNamespaces', mod_id if (providers.gradleProperty('integratedWorldSmoke').isPresent()) { systemProperty 'basemetals.integratedWorldSmoke', 'true' @@ -293,9 +235,7 @@ minecraft { args '--nogui' } - // This run config launches GameTestServer and runs all registered gametests, then exits. - // By default, the server will crash when no gametests are provided. - // The gametest system is also enabled by default for other run configs under the /test command. + // Runs the isolated validation source set and exits when the suite completes. gameTestServer { systemProperty 'forge.enabledGameTestNamespaces', mod_id mods { @@ -306,10 +246,9 @@ minecraft { } data { - // example of overriding the workingDirectory set in configureEach above workingDir.convention layout.projectDirectory.dir('run-data') - // Specify the modid for data generation, where to output the resulting resource, and where to look for existing resources. + // Regenerate the checked-in resources from the material catalogue. args '--mod', mod_id, '--all', '--output', file('src/generated/resources/'), '--existing', file('src/main/resources/') } } @@ -358,1154 +297,16 @@ tasks.withType(JavaExec).configureEach { JavaExec runTask -> } } -// Include resources generated by data generators. +// Generated resources are checked in; the local cache is never packaged. sourceSets.main.resources { srcDir 'src/generated/resources' exclude '.cache/**' } -def orespawnModule = "mmd-orespawn-${project.orespawn_curse_project_id}" -def orespawnVerificationRepository = project.findProperty('orespawnVerificationRepository') -repositories { - minecraft.mavenizer(it) - maven fg.forgeMaven - maven fg.minecraftLibsMaven - mavenCentral() - - // CI stages and hashes the exact public OreSpawn jar in a temporary Maven - // mirror. Normal development resolves the same coordinates from CurseMaven. - exclusiveContent { - forRepository { - maven { - name = orespawnVerificationRepository == null - ? 'CurseMaven' - : 'OreSpawnReleaseVerificationMirror' - url = orespawnVerificationRepository == null - ? uri('https://www.cursemaven.com') - : uri(orespawnVerificationRepository) - metadataSources { artifact() } - } - } - filter { - includeModule('curse.maven', orespawnModule) - } - } - - flatDir { - dir rootProject.file('../../benchmarks/external/EnderIO-1.18.2/build/libs') - } - maven { url = uri('https://modmaven.dev/') } - maven { url = uri('https://dvs1.progwml6.com/files/maven/') } - maven { url = uri('https://maven.covers1624.net/') } -} - -dependencies { - // Specify the version of Minecraft to use. - // Any artifact can be supplied so long as it has a "userdev" classifier artifact and is a compatible patcher artifact. - // The "userdev" classifier will be requested and setup by ForgeGradle. - // If the group id is "net.minecraft" and the artifact id is one of ["client", "server", "joined"], - // then special handling is done to allow a setup of a vanilla dependency without the use of an external repository. - implementation minecraft.dependency("net.minecraftforge:forge:${minecraft_version}-${forge_version}") - - // OreSpawn remains an ordinary hard mod dependency in metadata and is - // never shaded into Base Metals. Use the published 1.18.2 release in - // development so clean checkouts exercise the same artifact as players. - runtimeOnly renamer.dependency("curse.maven:mmd-orespawn-${orespawn_curse_project_id}:${orespawn_curse_file_id}") - orespawnRelease "curse.maven:mmd-orespawn-${orespawn_curse_project_id}:${orespawn_curse_file_id}" - - // API only: Mekanism remains optional at runtime. Its public slurry - // registry is required to provide the eight historical processing chains. - compileOnly 'mekanism:Mekanism:1.18.2-10.2.5.465:api' - - // Opt-in runtime distributions for compatibility smoke tests. These are - // never packaged, required, or resolved by ordinary Base Metals builds. - def compatibilitySmokes = providers.gradleProperty('compatSmoke').getOrElse('') - .split(',').collect { it.trim().toLowerCase(Locale.ROOT) }.findAll { !it.isEmpty() } - if (compatibilitySmokes.contains('mekanism') || compatibilitySmokes.contains('all')) { - runtimeOnly renamer.dependency('mekanism:Mekanism:1.18.2-10.2.5.465') - } - if (compatibilitySmokes.contains('tconstruct') || compatibilitySmokes.contains('all')) { - runtimeOnly renamer.dependency('slimeknights.mantle:Mantle:1.18.2-1.9.55') - runtimeOnly renamer.dependency('slimeknights.tconstruct:TConstruct:1.18.2-3.7.2.169') - } - if (compatibilitySmokes.contains('thermal') || compatibilitySmokes.contains('all')) { - runtimeOnly renamer.dependency('com.teamcofh:cofh_core:1.18.2-9.2.3.47') - runtimeOnly renamer.dependency('com.teamcofh:thermal_foundation:1.18.2-9.2.2.59') - runtimeOnly renamer.dependency('com.teamcofh:thermal_expansion:1.18.2-9.2.2.24') - } - if (compatibilitySmokes.contains('enderio') || compatibilitySmokes.contains('all')) { - runtimeOnly renamer.dependency('validation:EnderIO-base-1.18.2:6.0.0-alpha-a94a2cb') - runtimeOnly renamer.dependency('validation:EnderIO-machines-1.18.2:6.0.0-alpha-a94a2cb') - } - - testImplementation platform('org.junit:junit-bom:5.10.2') - testImplementation 'org.junit.jupiter:junit-jupiter' - testRuntimeOnly 'org.junit.platform:junit-platform-launcher:1.10.2' - - // Real mod deobf dependency examples - these get remapped to your current mappings - // compileOnly fg.deobf("mezz.jei:jei-${mc_version}:${jei_version}:api") // Adds JEI API as a compile dependency - // runtimeOnly renamer.dependency("mezz.jei:jei-${mc_version}:${jei_version}") // Adds the full JEI mod as a runtime dependency - // implementation fg.deobf("com.tterrag.registrate:Registrate:MC${mc_version}-${registrate_version}") // Adds registrate as a dependency - - // Example mod dependency using a mod jar from ./libs with a flat dir repository - // This maps to ./libs/coolmod-${mc_version}-${coolmod_version}.jar - // The group id is ignored when searching -- in this case, it is "blank" - // implementation fg.deobf("blank:coolmod-${mc_version}:${coolmod_version}") - - // For more info: - // http://www.gradle.org/docs/current/userguide/artifact_dependencies_tutorial.html - // http://www.gradle.org/docs/current/userguide/dependency_management.html -} - -renamer.mappings.from minecraft.dependency.toSrgFile - -configurations.named('testRuntimeClasspath') { - exclude group: 'curse.maven', module: orespawnModule -} - -// This block of code expands all declared replace properties in the specified resource targets. -// A missing property will result in an error. Properties are expanded using ${} Groovy notation. -// When "copyIdeResources" is enabled, this will also run before the game launches in IDE environments. -// See https://docs.gradle.org/current/dsl/org.gradle.language.jvm.tasks.ProcessResources.html -tasks.named('processResources', ProcessResources).configure { - var replaceProperties = [ - minecraft_version: minecraft_version, minecraft_version_range: minecraft_version_range, - forge_version: forge_version, forge_version_range: forge_version_range, - loader_version_range: loader_version_range, - mod_id: mod_id, mod_name: mod_name, mod_license: mod_license, mod_version: mod_version, - mod_authors: mod_authors, mod_description: mod_description, - ] - inputs.properties replaceProperties - - filesMatching(['META-INF/mods.toml', 'pack.mcmeta']) { - expand replaceProperties + [project: project] - } - - from('docs') { - into 'META-INF/basemetals/docs' - } -} - -// Eclipse launches use this processed directory as a stable metadata/resource -// root. This prevents a transient Gradle resolution failure immediately before -// launch from starting Forge with Java classes but no Base Metals data pack. -tasks.matching { it.name in ['genEclipseRuns', 'eclipse'] }.configureEach { - dependsOn tasks.named('processResources') -} - -def expectedMavenGroup = 'zone.moddev.mc.basemetals' -def expectedMavenArtifact = 'BaseMetals' -def expectedMavenCoordinate = "${expectedMavenGroup}:${expectedMavenArtifact}:${project.version}" - -tasks.named('jar', Jar) { - archiveClassifier = 'deobf' - destinationDirectory = layout.buildDirectory.dir('libs-dev') - manifest { - attributes([ - 'Maven-Artifact' : expectedMavenCoordinate, - 'Specification-Title' : 'Base Metals', - 'Specification-Vendor' : project.mod_authors, - 'Specification-Version' : '1', - 'Implementation-Title' : 'Base Metals', - 'Implementation-Version': project.version, - 'Implementation-Vendor' : project.mod_authors, - 'Built-On-Java' : '17', - 'Built-On' : "${project.minecraft_version}-${project.forge_version}", - 'Build-Target' : "minecraft-${project.minecraft_version}-${project.loader_name}-${project.forge_version}-java-${project.java_version}" - ]) - } -} - -def releaseJar = renamer.classes(tasks.named('jar', Jar)) { - map.from minecraft.dependency.toSrgFile - archiveClassifier = null - output = layout.buildDirectory.file( - "libs/${base.archivesName.get()}-${project.version}.jar") -} - -['apiElements', 'runtimeElements'].each { configurationName -> - configurations.named(configurationName) { artifacts.clear() } - artifacts { add(configurationName, releaseJar) } -} - -tasks.named('assemble') { - dependsOn releaseJar - dependsOn tasks.named('sourcesJar') - dependsOn tasks.named('javadocJar') -} - -def mainReleaseName = "BaseMetals-${project.version}.jar" -def sourcesReleaseName = "BaseMetals-${project.version}-sources.jar" -def javadocReleaseName = "BaseMetals-${project.version}-javadoc.jar" -def expectedReleaseNames = [mainReleaseName, sourcesReleaseName, javadocReleaseName] - -def packagedServerSmokeJar = tasks.register('packagedServerSmokeJar', Jar) { - dependsOn tasks.named(sourceSets.packagedServerSmoke.classesTaskName) - archiveFileName = 'basemetals-server-smoke-dev.jar' - destinationDirectory = layout.buildDirectory.dir('packaged-smoke-probes') - from sourceSets.packagedServerSmoke.output.classesDirs - from 'src/packagedServerSmoke/packaged-resources' -} -def packagedServerSmokeProbe = renamer.classes(packagedServerSmokeJar) { - map.from minecraft.dependency.toSrgFile - output = layout.buildDirectory.file('packaged-smoke-probes/basemetals-server-smoke.jar') -} - -def packagedClientSmokeJar = tasks.register('packagedClientSmokeJar', Jar) { - dependsOn tasks.named(sourceSets.integratedSmoke.classesTaskName) - dependsOn tasks.named(sourceSets.packagedClientSmoke.classesTaskName) - archiveFileName = 'basemetals-client-smoke-dev.jar' - destinationDirectory = layout.buildDirectory.dir('packaged-smoke-probes') - from sourceSets.integratedSmoke.output.classesDirs - from sourceSets.packagedClientSmoke.output.classesDirs - from 'src/packagedClientSmoke/packaged-resources' -} -def packagedClientSmokeProbe = renamer.classes(packagedClientSmokeJar) { - map.from minecraft.dependency.toSrgFile - output = layout.buildDirectory.file('packaged-smoke-probes/basemetals-client-smoke.jar') -} - -def preparedReleaseDir = project.findProperty('preparedReleaseDir') -def mavenUploadUrl = System.getenv('MAVEN_UPLOAD_URL') ?: 'https://invalid.invalid/missing-maven-upload-url' -def mavenUploadUsername = System.getenv('MAVEN_UPLOAD_USERNAME') ?: '' -def mavenUploadPassword = System.getenv('MAVEN_UPLOAD_PASSWORD') ?: '' - -publishing { - publications { - mavenJava(MavenPublication) { - groupId = project.group - artifactId = expectedMavenArtifact - version = project.version - if (preparedReleaseDir != null) { - artifact new File(preparedReleaseDir.toString(), mainReleaseName) - artifact(new File(preparedReleaseDir.toString(), sourcesReleaseName)) { - classifier = 'sources' - } - artifact(new File(preparedReleaseDir.toString(), javadocReleaseName)) { - classifier = 'javadoc' - } - } else { - artifact releaseJar - artifact tasks.named('sourcesJar') - artifact tasks.named('javadocJar') - } - pom.withXml { - def root = asNode() - root.appendNode('name', 'Base Metals') - root.appendNode('description', - 'Historically inspired and fantasy metals, equipment, processing, and OreSpawn provider data.') - root.appendNode('url', 'https://github.com/MinecraftModDevelopmentMods/BaseMetals') - def license = root.appendNode('licenses').appendNode('license') - license.appendNode('name', 'GNU Lesser General Public License, Version 2.1') - license.appendNode('url', 'https://www.gnu.org/licenses/old-licenses/lgpl-2.1.txt') - } - } - } - repositories { - maven { - name = 'release' - url = uri(mavenUploadUrl) - credentials { - username = mavenUploadUsername - password = mavenUploadPassword - } - } - } -} - -def sha256 = { File artifact -> - MessageDigest.getInstance('SHA-256').digest(artifact.bytes).encodeHex().toString().toUpperCase() -} - -def configuredReleaseVersion = project.version.toString() -def configuredReleaseGroup = project.group.toString() -def configuredJavaToolchainVersion = project.java_toolchain_version.toString() -def configuredReleaseIdentity = [ - modVersion: project.mod_version.toString(), - modGroup: project.mod_group.toString(), - minecraftVersion: project.minecraft_version.toString(), - forgeVersion: project.forge_version.toString(), - forgeInstallerSha256: project.forge_installer_sha256.toString(), - mappingChannel: project.mapping_channel.toString(), - mappingVersion: project.mapping_version.toString(), - loaderName: project.loader_name.toString(), - loaderCode: project.loader_code.toString(), - javaVersion: project.java_version.toString(), - gradleJavaVersion: project.gradle_java_version.toString(), - curseForgeProjectId: project.curseforge_project_id.toString() -] -def configuredOreSpawnIdentity = [ - version: project.orespawn_version.toString(), - curseProjectId: project.orespawn_curse_project_id.toString(), - curseFileId: project.orespawn_curse_file_id.toString(), - sha256: project.orespawn_sha256.toString() -] - -tasks.register('verifyJava17Toolchain') { - group = 'verification' - description = 'Verifies the exact Java product toolchain used for Base Metals compilation.' - dependsOn tasks.named('compileJava') - doLast { - def metadata = tasks.named('compileJava', JavaCompile).get().javaCompiler.get().metadata - if (configuredJavaToolchainVersion != '17.0.1+12' - || metadata.languageVersion.asInt() != 17 - || metadata.jvmVersion != configuredJavaToolchainVersion - || !metadata.vendor.toString().toLowerCase(Locale.ROOT).contains('temurin')) { - throw new GradleException("Expected Eclipse Temurin ${configuredJavaToolchainVersion}, found " + - "${metadata.vendor} ${metadata.jvmVersion} at ${metadata.installationPath}") - } - } -} - -tasks.register('verifyMavenCoordinates') { - group = 'verification' - description = 'Verifies the generated POM uses the Base Metals release namespace.' - dependsOn tasks.named('generatePomFileForMavenJavaPublication') - doLast { - File pomFile = file("${buildDir}/publications/mavenJava/pom-default.xml") - if (!pomFile.isFile()) throw new GradleException("Generated Maven POM does not exist: ${pomFile}") - def pom = new XmlSlurper(false, false).parse(pomFile) - def actual = [pom.groupId.text(), pom.artifactId.text(), pom.version.text()] - def expected = [expectedMavenGroup, expectedMavenArtifact, configuredReleaseVersion] - if (configuredReleaseGroup != expectedMavenGroup || actual != expected) { - throw new GradleException("Expected Maven coordinate ${expected.join(':')}, found ${actual.join(':')}") - } - } -} - -tasks.register('verifyMavenizerCompatibilityFixture') { - group = 'verification' - description = 'Verifies the checksum-sealed Forge 1.18.2 Mavenizer compatibility fixture.' - inputs.file mavenizerCompatibilityJar - doLast { - File fixture = mavenizerCompatibilityJar.asFile - if (!fixture.isFile()) throw new GradleException("Missing Mavenizer compatibility fixture: ${fixture}") - String actual = sha256(fixture) - if (actual != mavenizerCompatibilitySha256) { - throw new GradleException("Mavenizer compatibility fixture checksum mismatch: ${actual}") - } - } -} - -tasks.register('verifyReleaseConfiguration') { - group = 'verification' - description = 'Verifies the exact Base Metals 1.18.2 release identity.' - dependsOn tasks.named('verifyMavenizerCompatibilityFixture') - dependsOn tasks.named('verifyJava17Toolchain') - doLast { - if (configuredReleaseIdentity.modVersion != '3.0.1.118021' - || configuredReleaseIdentity.modGroup != expectedMavenGroup - || configuredReleaseIdentity.minecraftVersion != '1.18.2' - || configuredReleaseIdentity.forgeVersion != '40.3.0' - || configuredReleaseIdentity.forgeInstallerSha256 != '9434C29790504DCD11CE97CB30DD8891B4B18848982357BC1B5ABFA79FB95103' - || configuredReleaseIdentity.mappingChannel != 'official' - || configuredReleaseIdentity.mappingVersion != '1.18.2' - || configuredReleaseIdentity.loaderName != 'forge' - || configuredReleaseIdentity.loaderCode != '1' - || configuredReleaseIdentity.javaVersion != '17' - || configuredReleaseIdentity.gradleJavaVersion != '17' - || configuredReleaseIdentity.curseForgeProjectId != '240967') { - throw new GradleException('Unexpected Base Metals 1.18.2 release identity') - } - } -} - -tasks.register('verifyReleaseDependencies') { - group = 'verification' - description = 'Resolves and verifies the exact released OreSpawn qualification dependency.' - doLast { - if (configuredOreSpawnIdentity.version != '4.0.16.118021' - || configuredOreSpawnIdentity.curseProjectId != '245586' - || configuredOreSpawnIdentity.curseFileId != '8780769' - || configuredOreSpawnIdentity.sha256 != '76FD6140FE85C3D9EEA0321A0441D352A0B2643390A1090BBB1F70FE104C5717') { - throw new GradleException('Unexpected OreSpawn release coordinates') - } - File artifact = configurations.orespawnRelease.resolvedConfiguration.resolvedArtifacts - .find { it.moduleVersion.id.group == 'curse.maven' && it.file.name.endsWith('.jar') }?.file - if (artifact == null || !artifact.isFile()) { - throw new GradleException('Released OreSpawn artifact did not resolve') - } - String actual = sha256(artifact) - if (actual != configuredOreSpawnIdentity.sha256) { - throw new GradleException("OreSpawn SHA-256 mismatch: ${actual}") - } - } -} - -def forbiddenReleasePath = { String entryName -> - String name = entryName.replace('\\', '/').toLowerCase(Locale.ROOT) - name.startsWith('com/mcmoddev/') - || name.startsWith('ci-fixtures/') - || name.startsWith('reference/') - || name.startsWith('agent-notes/') - || name.contains('/agent-notes/') - || name.startsWith('src/gametest/') - || name.startsWith('src/integratedsmoke/') - || name.startsWith('src/packagedclientsmoke/') - || name.startsWith('src/upgradesmoke/') - || name.startsWith('src/packagedserversmoke/') - || name.contains('minecraft-mavenizer') - || name.contains('gametests.class') - || name.contains('/smoke/') - || name.endsWith('/oregenerator.class') - || name.endsWith('/worldgenerator.class') -} - -tasks.register('verifyReleaseArtifacts') { - group = 'verification' - description = 'Audits the deterministic release jars and their production-only contents.' - dependsOn tasks.named('verifyReleaseConfiguration') - dependsOn tasks.named('verifyReleaseDependencies') - dependsOn tasks.named('verifyMavenCoordinates') - dependsOn tasks.named('assemble') - doLast { - File libs = file("${buildDir}/libs") - def actual = fileTree(libs) { include 'BaseMetals-*.jar' }.files.collect { it.name }.sort() - def expected = expectedReleaseNames.sort() - if (actual != expected) { - throw new GradleException("Expected exactly ${expected}, found ${actual}") - } - expectedReleaseNames.each { artifactName -> - new ZipFile(new File(libs, artifactName)).withCloseable { zip -> - def leaked = zip.entries().toList().find { forbiddenReleasePath(it.name) } - if (leaked != null) { - throw new GradleException("${artifactName} contains validation, fixture, legacy, or agent-only path ${leaked.name}") - } - } - } - - File main = new File(libs, mainReleaseName) - new ZipFile(main).withCloseable { zip -> - def names = zip.entries().toList().collect { it.name } as Set - [ - 'META-INF/mods.toml', - 'META-INF/MANIFEST.MF', - 'pack.mcmeta', - 'data/basemetals/orespawn/provider.json', - 'data/basemetals/recipes/stone_crackhammer.json', - 'data/forge/loot_modifiers/global_loot_modifiers.json', - 'assets/basemetals/lang/en_us.json', - 'META-INF/basemetals/docs/VERSIONS.md', - 'META-INF/basemetals/docs/schemas/orespawn-4.0.16/orespawn-provider.schema.json' - ].each { required -> - if (!names.contains(required)) throw new GradleException("Release jar is missing ${required}") - } - def manifest = new java.util.jar.Manifest(zip.getInputStream(zip.getEntry('META-INF/MANIFEST.MF'))) - if (manifest.mainAttributes.getValue('Maven-Artifact') != expectedMavenCoordinate) { - throw new GradleException('Release manifest contains the wrong Maven coordinate') - } - String expectedBuildTarget = 'minecraft-1.18.2-forge-40.3.0-java-17' - if (manifest.mainAttributes.getValue('Build-Target') != expectedBuildTarget) { - throw new GradleException('Release manifest contains the wrong stable build target') - } - String modsToml = zip.getInputStream(zip.getEntry('META-INF/mods.toml')).getText('UTF-8') - if (modsToml.contains('${') - || !modsToml.contains('version="3.0.1.118021"') - || !modsToml.contains('versionRange="[4.0.1,5.0.0)"')) { - throw new GradleException('Release mods.toml contains unresolved or incompatible metadata') - } - def provider = new JsonSlurper().parse(zip.getInputStream( - zip.getEntry('data/basemetals/orespawn/provider.json'))) - if (provider.schema_version != 3 || provider.provider_revision != 1 - || provider.provider_modid != 'basemetals') { - throw new GradleException('OreSpawn provider schema/revision identity changed') - } - def locales = names.findAll { it ==~ /assets\/basemetals\/lang\/.+\.json/ } - if (locales.size() != 18) { - throw new GradleException("Release jar must contain all 18 language files, found ${locales.size()}") - } - if (names.any { it.endsWith('.class') && (it.contains('/gametest/') - || it.contains('/smoke/') || it.startsWith('com/mcmoddev/')) }) { - throw new GradleException('Release jar contains validation or legacy implementation classes') - } - } - } -} - -tasks.register('writeReleaseChecksums') { - group = 'verification' - description = 'Writes SHA-256 checksums for exactly the three release jars.' - dependsOn tasks.named('verifyReleaseArtifacts') - doLast { - File output = file("${buildDir}/release/SHA256SUMS") - output.parentFile.mkdirs() - output.setText(expectedReleaseNames.sort().collect { name -> - "${sha256(file("${buildDir}/libs/${name}")).toLowerCase(Locale.ROOT)} ${name}" - }.join('\n') + '\n', 'UTF-8') - } -} - -tasks.register('verifyPreparedReleaseArtifacts') { - group = 'verification' - description = 'Verifies the immutable artifact bundle supplied to Maven publication.' - inputs.files { - preparedReleaseDir == null ? [] : - (expectedReleaseNames + ['SHA256SUMS', 'CHANGELOG.txt']).collect { - new File(preparedReleaseDir.toString(), it) - } - } - doLast { - if (preparedReleaseDir == null) { - throw new GradleException('preparedReleaseDir is required for prepared artifact publication') - } - File prepared = file(preparedReleaseDir.toString()) - if (!prepared.isDirectory()) throw new GradleException("Prepared release directory does not exist: ${prepared}") - def expectedBundle = (expectedReleaseNames + ['SHA256SUMS', 'CHANGELOG.txt']).sort() - def actualBundle = prepared.listFiles().findAll { it.isFile() }.collect { it.name }.sort() - if (actualBundle != expectedBundle) { - throw new GradleException("Expected prepared bundle ${expectedBundle}, found ${actualBundle}") - } - def declared = [:] - new File(prepared, 'SHA256SUMS').readLines('UTF-8').findAll { !it.trim().isEmpty() }.each { line -> - def match = line =~ /^([0-9a-fA-F]{64}) (.+)$/ - if (!match.matches()) throw new GradleException("Invalid prepared checksum line: ${line}") - declared[match[0][2]] = match[0][1].toUpperCase(Locale.ROOT) - } - if (declared.keySet().sort() != expectedReleaseNames.sort()) { - throw new GradleException('Prepared checksums do not declare exactly the three release jars') - } - declared.each { name, expectedHash -> - if (sha256(new File(prepared, name)) != expectedHash) { - throw new GradleException("Prepared checksum mismatch for ${name}") - } - } - } -} - -tasks.register('validateMavenReleaseCredentials') { - group = 'publishing' - description = 'Prevents Maven publication from targeting an incomplete or local repository.' - doLast { - if (!System.getenv('MAVEN_UPLOAD_URL') - || !System.getenv('MAVEN_UPLOAD_USERNAME') - || !System.getenv('MAVEN_UPLOAD_PASSWORD')) { - throw new GradleException('MAVEN_UPLOAD_URL, MAVEN_UPLOAD_USERNAME, and MAVEN_UPLOAD_PASSWORD are required') - } - if (System.getenv('MAVEN_UPLOAD_URL').startsWith('file:')) { - throw new GradleException('Maven release publication must use a remote repository') - } - } -} - -tasks.withType(PublishToMavenRepository).configureEach { - dependsOn tasks.named('validateMavenReleaseCredentials') - dependsOn tasks.named('verifyMavenCoordinates') - dependsOn(preparedReleaseDir == null - ? tasks.named('verifyReleaseArtifacts') - : tasks.named('verifyPreparedReleaseArtifacts')) -} - -tasks.withType(JavaCompile).configureEach { - options.encoding = 'UTF-8' // Use the UTF-8 charset for Java compilation -} - -tasks.named('test', Test).configure { - useJUnitPlatform() -} - -def prepareCustomDimensionSmoke = tasks.register('prepareCustomDimensionSmoke', Sync) { - group = 'verification' - description = 'Installs the runtime-only custom-dimension datapack into the disposable smoke world.' - from layout.projectDirectory.dir('src/smoke/datapack') - into layout.projectDirectory.dir('run/smoke-custom-dimension/datapacks/basemetals-smoke') -} - -tasks.matching { it.name == 'runServer' }.configureEach { - if (providers.gradleProperty('smokeCustomDimension').isPresent()) { - dependsOn prepareCustomDimensionSmoke - } -} - -def upgradeWorldSmokeRequested = providers.gradleProperty('upgradeWorldSmoke').isPresent() -def legacyWorldSmokeRequested = providers.gradleProperty('legacyWorldSmoke').isPresent() -def configuredUpgradeWorldName = upgradeWorldSmokeRequested - ? providers.gradleProperty('upgradeWorldName').orNull - : providers.gradleProperty('legacyWorldName').orNull -def upgradeSmokeResultFile = configuredUpgradeWorldName == null - ? null - : layout.projectDirectory.file("run-upgrade/${configuredUpgradeWorldName}/BASEMETALS_UPGRADE_SMOKE_RESULT.txt") - -def verifyUpgradeWorldSmokeResult = tasks.register('verifyUpgradeWorldSmokeResult') { - group = 'verification' - description = 'Fails unless the selected upgraded-world smoke writes its expected success marker.' - onlyIf { upgradeWorldSmokeRequested || legacyWorldSmokeRequested } - doLast { - if (configuredUpgradeWorldName == null || configuredUpgradeWorldName.isBlank()) { - throw new GradleException('An upgraded-world smoke requires -PupgradeWorldName or -PlegacyWorldName') - } - if (configuredUpgradeWorldName.contains('/') || configuredUpgradeWorldName.contains('\\') - || configuredUpgradeWorldName == '.' || configuredUpgradeWorldName == '..') { - throw new GradleException("Invalid upgraded-world smoke name: ${configuredUpgradeWorldName}") - } - File result = upgradeSmokeResultFile.asFile - if (!result.isFile()) { - throw new GradleException("Upgraded-world smoke did not write ${result}") - } - String expected = upgradeWorldSmokeRequested - ? 'BASEMETALS_UPGRADE_SMOKE PASS' - : 'BASEMETALS_LEGACY_WORLD_SMOKE PASS' - String actual = result.getText('UTF-8').trim() - if (!actual.startsWith(expected)) { - throw new GradleException("Upgraded-world smoke failed: ${actual}") - } - } -} - -tasks.matching { it.name == 'runServer' }.configureEach { - if (upgradeWorldSmokeRequested || legacyWorldSmokeRequested) { - finalizedBy verifyUpgradeWorldSmokeResult - doFirst { - if (configuredUpgradeWorldName == null || configuredUpgradeWorldName.isBlank()) { - throw new GradleException('An upgraded-world smoke requires -PupgradeWorldName or -PlegacyWorldName') - } - if (configuredUpgradeWorldName.contains('/') || configuredUpgradeWorldName.contains('\\') - || configuredUpgradeWorldName == '.' || configuredUpgradeWorldName == '..') { - throw new GradleException("Invalid upgraded-world smoke name: ${configuredUpgradeWorldName}") - } - delete upgradeSmokeResultFile - } - } -} - -def generatedResourcesDir = layout.projectDirectory.dir('src/generated/resources') -def generatedSnapshotFile = layout.buildDirectory.file('tmp/generated-data-before.json') -def generatedFingerprint = { - fileTree(generatedResourcesDir).matching { exclude '.cache/**' }.files - .findAll { it.isFile() } - .sort { a, b -> a.absolutePath <=> b.absolutePath } - .collectEntries { File resource -> - String relative = generatedResourcesDir.asFile.toPath().relativize(resource.toPath()) - .toString().replace('\\', '/') - byte[] digest = java.security.MessageDigest.getInstance('SHA-256').digest(resource.bytes) - [(relative): digest.encodeHex().toString()] - } -} - -def snapshotGeneratedData = tasks.register('snapshotGeneratedData') { - group = 'verification' - description = 'Snapshots generated resources before data generation.' - doLast { - File snapshot = generatedSnapshotFile.get().asFile - snapshot.parentFile.mkdirs() - snapshot.setText(groovy.json.JsonOutput.toJson(generatedFingerprint()), 'UTF-8') - } -} - -tasks.matching { it.name == 'runData' }.configureEach { - mustRunAfter snapshotGeneratedData -} - -def verifyGeneratedData = tasks.register('verifyGeneratedData') { - group = 'verification' - description = 'Fails when runData changes checked-in generated resources.' - dependsOn snapshotGeneratedData, 'runData' - doLast { - Map before = new groovy.json.JsonSlurper().parse(generatedSnapshotFile.get().asFile) as Map - Map after = generatedFingerprint() - if (before != after) { - Set changed = new TreeSet<>(before.keySet() + after.keySet()) - changed.removeIf { before[it] == after[it] } - throw new GradleException("Generated-resource drift detected after runData: ${changed.join(', ')}") - } - } -} - -tasks.named('check').configure { - dependsOn verifyGeneratedData - dependsOn tasks.named('verifyReleaseDependencies') - dependsOn tasks.named('verifyMavenCoordinates') - dependsOn tasks.named('verifyReleaseConfiguration') -} - -def prepareGameTestStructures = tasks.register('prepareGameTestStructures', Copy) { - group = 'verification' - description = 'Copies the tracked SNBT fixtures into the GameTest working directory.' - from layout.projectDirectory.dir('src/gametest/resources/gameteststructures') - into layout.projectDirectory.dir('run/gameteststructures') -} - -tasks.matching { it.name == 'prepareRunGameTestServer' }.configureEach { - dependsOn prepareGameTestStructures -} - -tasks.matching { it.name == 'runGameTestServer' }.configureEach { - dependsOn prepareGameTestStructures -} - -def packagedForgeServerRuntime = providers.gradleProperty('packagedForgeServerRuntime') -def packagedForgeClientRuntime = providers.gradleProperty('packagedForgeClientRuntime') -def packagedClientJavaExecutable = providers.gradleProperty('packagedClientJavaExecutable') -def packagedServerRunDirectory = layout.buildDirectory.dir('packaged-server-run') -def packagedClientRunDirectory = layout.buildDirectory.dir('packaged-client-run') - -def requireRuntimeDirectory = { Provider configuredPath, String propertyName -> - if (!configuredPath.isPresent()) { - throw new GradleException("Pass -P${propertyName}=") - } - File runtime = file(configuredPath.get()) - if (!runtime.isDirectory()) throw new GradleException("${propertyName} does not name a directory: ${runtime}") - runtime -} - -def verifyPackagedLog = { File runDirectory, String passMarker, String label -> - File log = new File(runDirectory, 'logs/latest.log') - if (!log.isFile()) throw new GradleException("${label} did not create logs/latest.log") - String text = log.getText('UTF-8') - if (!text.contains(passMarker)) throw new GradleException("${label} completion marker is absent from latest.log") - ['Failed to load correctly', 'Exception stopping the server', 'This crash report has been saved to'].each { failure -> - if (text.contains(failure)) throw new GradleException("${label} log contains fatal marker: ${failure}") - } -} - -def preparePackagedServerSmoke = tasks.register('preparePackagedServerSmoke') { - group = 'verification' - description = 'Stages the public Base Metals and OreSpawn jars with an isolated dedicated-server probe.' - dependsOn releaseJar - dependsOn packagedServerSmokeProbe - dependsOn tasks.named('verifyReleaseDependencies') - doLast { - File runDirectory = packagedServerRunDirectory.get().asFile - delete runDirectory - runDirectory.mkdirs() - copy { - from releaseJar - from packagedServerSmokeProbe - from configurations.orespawnRelease - into new File(runDirectory, 'mods') - } - new File(runDirectory, 'server.properties').setText('''\ -level-name=packaged-server-smoke-world -level-seed=basemetals-release-smoke -online-mode=false -server-port=0 -allow-nether=true -generate-structures=false -spawn-protection=0 -max-tick-time=-1 -''', 'UTF-8') - new File(runDirectory, 'eula.txt').setText('eula=true\n', 'UTF-8') - } -} - -def packagedServerStartupSmoke = tasks.register('packagedServerStartupSmoke', Exec) { - group = 'verification' - description = 'Starts an official Forge dedicated server with the exact packaged release jars.' - dependsOn preparePackagedServerSmoke - doFirst { - File runtime = requireRuntimeDirectory(packagedForgeServerRuntime, 'packagedForgeServerRuntime') - File libraries = new File(runtime, 'libraries') - File forgeDirectory = new File(libraries, 'net/minecraftforge/forge/1.18.2-40.3.0') - File launcher = new File(forgeDirectory, 'forge-1.18.2-40.3.0-server.jar') - File sourceArguments = new File(forgeDirectory, - System.getProperty('os.name').toLowerCase(Locale.ROOT).contains('windows') - ? 'win_args.txt' : 'unix_args.txt') - [launcher, sourceArguments, libraries].each { - if (!it.exists()) throw new GradleException("Incomplete official server runtime: ${it}") - } - File runDirectory = packagedServerRunDirectory.get().asFile - File absoluteArguments = new File(runDirectory, 'forge-server-args.txt') - String libraryPath = libraries.canonicalPath.replace('\\', '/') + '/' - absoluteArguments.setText(sourceArguments.getText('UTF-8') - .replace('libraries/', libraryPath) - .replace('-DlibraryDirectory=libraries', - "-DlibraryDirectory=${libraries.canonicalPath}"), 'UTF-8') - workingDir runDirectory - commandLine java17Launcher.get().executablePath.asFile.absolutePath, - '-Xms512m', '-Xmx2g', "@${absoluteArguments.absolutePath}", 'nogui' - } - doLast { - File runDirectory = packagedServerRunDirectory.get().asFile - File marker = new File(runDirectory, 'packaged-server-smoke-pass.properties') - if (!marker.isFile()) throw new GradleException('Packaged dedicated-server completion marker is missing') - Properties values = new Properties() - marker.withInputStream { values.load(it) } - if (values.getProperty('basemetals') != '3.0.1.118021' - || values.getProperty('orespawn') != '4.0.16.118021' - || values.getProperty('forge') != '40.3.0') { - throw new GradleException("Packaged dedicated-server versions are wrong: ${values}") - } - verifyPackagedLog(runDirectory, 'BASEMETALS_PACKAGED_SERVER_SMOKE PASS', - 'Packaged dedicated-server smoke') - } -} - -def preparePackagedClientSmoke = tasks.register('preparePackagedClientSmoke') { - group = 'verification' - description = 'Stages the public Base Metals and OreSpawn jars with the isolated integrated-client probe.' - dependsOn releaseJar - dependsOn packagedClientSmokeProbe - dependsOn tasks.named('verifyReleaseDependencies') - doLast { - File runDirectory = packagedClientRunDirectory.get().asFile - delete runDirectory - runDirectory.mkdirs() - copy { - from releaseJar - from packagedClientSmokeProbe - from configurations.orespawnRelease - into new File(runDirectory, 'mods') - } - File probeJar = new File(runDirectory, 'mods/basemetals-client-smoke.jar') - new java.util.zip.ZipFile(probeJar).withCloseable { zip -> - ['META-INF/mods.toml', 'pack.mcmeta'].each { requiredEntry -> - if (zip.getEntry(requiredEntry) == null) { - throw new GradleException("Packaged client smoke probe is missing ${requiredEntry}") - } - } - } - new File(runDirectory, 'options.txt').setText('fullscreen:false\nlang:en_us\n', 'UTF-8') - } -} - -def packagedClientStartupSmoke = tasks.register('packagedClientStartupSmoke', Exec) { - group = 'verification' - description = 'Starts an official Forge client with the exact packaged release jars and integrated-world probe.' - dependsOn preparePackagedClientSmoke - doFirst { - File runtime = requireRuntimeDirectory(packagedForgeClientRuntime, 'packagedForgeClientRuntime') - String forgeVersionId = ['1.18.2-forge-40.3.0', 'forge-40.3.0'].find { candidate -> - new File(runtime, "versions/${candidate}/${candidate}.json").isFile() - } - if (forgeVersionId == null) throw new GradleException('Official client runtime has no Forge 40.3.0 profile') - File forgeJsonFile = new File(runtime, "versions/${forgeVersionId}/${forgeVersionId}.json") - File baseJsonFile = new File(runtime, 'versions/1.18.2/1.18.2.json') - File baseJar = new File(runtime, 'versions/1.18.2/1.18.2.jar') - File nativesDirectory = new File(runtime, "natives/${forgeVersionId}") - [forgeJsonFile, baseJsonFile, baseJar, new File(runtime, 'libraries'), - new File(runtime, 'assets'), nativesDirectory].each { - if (!it.exists()) throw new GradleException("Incomplete official client runtime: ${it}") - } - def slurper = new JsonSlurper() - Map forgeJson = (Map) slurper.parse(forgeJsonFile) - Map baseJson = (Map) slurper.parse(baseJsonFile) - String currentOsName = System.getProperty('os.name').toLowerCase(Locale.ROOT).with { name -> - name.contains('windows') ? 'windows' : (name.contains('mac') ? 'osx' : 'linux') - } - String currentOsArch = System.getProperty('os.arch') - Map classpathByModule = new LinkedHashMap<>() - [baseJson, forgeJson].each { Map metadata -> - ((List) metadata.libraries).each { Map library -> - List rules = (List) library.rules - boolean allowed = rules == null || rules.isEmpty() - if (rules != null) { - rules.each { Map rule -> - Map os = (Map) rule.os - boolean matches = os == null || (os.name == currentOsName - && (os.arch == null || os.arch == currentOsArch)) - if (matches) allowed = rule.action == 'allow' - } - } - if (!allowed) return - String relative = (String) ((Map) ((Map) library.downloads).artifact).path - File artifact = new File(runtime, "libraries/${relative}") - if (!artifact.isFile()) throw new GradleException("Missing official client library: ${artifact}") - List coordinates = ((String) library.name).split(':') as List - String module = coordinates.size() >= 2 - ? "${coordinates[0]}:${coordinates[1]}" : (String) library.name - classpathByModule.put(module, artifact) - } - } - List classpathFiles = new ArrayList<>(classpathByModule.values()) - classpathFiles.add(baseJar) - String libraryDirectory = new File(runtime, 'libraries').absolutePath - List forgeJvmArguments = ((List) ((Map) forgeJson.arguments).jvm) - .collect { String argument -> - argument.replace('${library_directory}', libraryDirectory) - .replace('${classpath_separator}', File.pathSeparator) - .replace('${version_name}', (String) forgeJson.inheritsFrom) - } - List gameArguments = [] - gameArguments.addAll((List) ((Map) forgeJson.arguments).game) - gameArguments.addAll([ - '--username', 'BaseMetalsValidation', - '--version', (String) forgeJson.id, - '--gameDir', packagedClientRunDirectory.get().asFile.absolutePath, - '--assetsDir', new File(runtime, 'assets').absolutePath, - '--assetIndex', (String) ((Map) baseJson.assetIndex).id, - '--uuid', '00000000-0000-0000-0000-000000000001', - '--accessToken', 'validation-token', - '--userType', 'legacy', - '--versionType', 'release', - '--width', '854', '--height', '480' - ]) - File runDirectory = packagedClientRunDirectory.get().asFile - workingDir runDirectory - File clientJava = packagedClientJavaExecutable.isPresent() - ? file(packagedClientJavaExecutable.get()) : java17Launcher.get().executablePath.asFile - if (!clientJava.isFile()) { - throw new GradleException("packagedClientJavaExecutable does not name a Java executable: ${clientJava}") - } - List command = [ - clientJava.absolutePath, - '-Xms512m', '-Xmx2g', - '-Dbasemetals.integratedWorldSmoke=true', - '-Dbasemetals.integratedWorldSmokeName=basemetals-packaged-client-smoke', - "-Djava.library.path=${nativesDirectory.absolutePath}", - '-Dminecraft.launcher.brand=basemetals-validation', - '-Dminecraft.launcher.version=1' - ] - command.addAll(forgeJvmArguments) - command.addAll(['-cp', classpathFiles.collect { it.absolutePath }.join(File.pathSeparator), - (String) forgeJson.mainClass]) - command.addAll(gameArguments) - commandLine(command) - } - doLast { - verifyPackagedLog(packagedClientRunDirectory.get().asFile, - 'BASEMETALS_INTEGRATED_SMOKE PASS', 'Packaged client smoke') - } -} - -tasks.register('packagedRuntimeSmoke') { - group = 'verification' - description = 'Runs both official packaged client and dedicated-server startup gates.' - dependsOn packagedServerStartupSmoke - dependsOn packagedClientStartupSmoke -} -// ForgeGradle 7 Eclipse launches are normalized after generation: they use the -// configured Gradle cache and Java 17, load only bin/main, and exclude all unit, -// GameTest, integrated-smoke, and upgrade-smoke code from production launches. -eclipse { - classpath { - downloadSources = true - downloadJavadoc = true - file.whenMerged { classpath -> - classpath.entries.findAll { entry -> - String path = entry.path?.replace('\\', '/') ?: '' - path.startsWith('src/test/') || path.startsWith('src/gametest/') - || path.startsWith('src/integratedSmoke/') || path.startsWith('src/packagedClientSmoke/') - || path.startsWith('src/upgradeSmoke/') - || path.startsWith('src/packagedServerSmoke/') - }.each { entry -> - entry.entryAttributes['test'] = 'true' - } - } - } - synchronizationTasks 'isolateEclipseProductionRuns' -} - -idea { - module { - downloadSources = true - downloadJavadoc = true - } -} - -def syncForge40EclipseLaunches = tasks.register('syncForge40EclipseLaunches') { - group = 'ide' - description = 'Normalizes Forge 40 Eclipse launches to the production-only merged output.' - dependsOn tasks.named('processResources') - doLast { - String mainOutput = new File(projectDir, 'bin/main').absolutePath - String modClasses = "${mod_id}%%${mainOutput}" - String eclipseGradleJavaHome = System.getenv('JAVA_HOME') ?: System.getProperty('java.home') - def escapePreference = { String value -> value.replace('\\', '\\\\').replace(':', '\\:') } - File buildshipPreferences = file('.settings/org.eclipse.buildship.core.prefs') - if (!buildshipPreferences.parentFile.isDirectory() && !buildshipPreferences.parentFile.mkdirs()) { - throw new GradleException("Could not create ${buildshipPreferences.parentFile}") - } - buildshipPreferences.setText([ - 'arguments=', - 'auto.sync=false', - 'build.scans.enabled=false', - 'connection.gradle.distribution=GRADLE_DISTRIBUTION(WRAPPER)', - "connection.gradle.user.home=${escapePreference(gradle.gradleUserHomeDir.absolutePath)}", - 'connection.project.dir=', - 'eclipse.preferences.version=1', - "gradle.user.home=${escapePreference(gradle.gradleUserHomeDir.absolutePath)}", - "java.home=${escapePreference(eclipseGradleJavaHome)}", - 'jvm.arguments=', - 'offline.mode=false', - 'override.workspace.settings=true', - 'show.console.view=false', - 'show.executions.view=false', - '' - ].join('\r\n'), 'UTF-8') - - File processedResources = layout.buildDirectory.dir('resources/main').get().asFile - copy { - from processedResources - into mainOutput - } - ['Client', 'Server'].each { String runName -> - File launch = file("run${runName}.launch") - if (!launch.isFile()) throw new GradleException("Missing generated Eclipse launch ${launch}") - String text = launch.getText('UTF-8') - if (text.contains('')) { - text = text.replace( - '', - '\r\n' + - " \r\n" + - '') - } - text = text.replaceFirst( - //, - java.util.regex.Matcher.quoteReplacement( - "")) - text = text.replaceFirst(//, - java.util.regex.Matcher.quoteReplacement("")) - - def programArguments = text =~ /()/ - if (programArguments.count != 1) { - throw new GradleException("ForgeGradle generated malformed program arguments in ${launch.name}") - } - String arguments = programArguments[0][2] - ['cache', 'metadata', 'to-srg', 'to-obf'].each { flag -> - String prefix = "--${flag} " - int valueStart = arguments.indexOf(prefix) - if (valueStart < 0) throw new GradleException("ForgeGradle omitted --${flag} from ${launch.name}") - valueStart += prefix.length() - int valueEnd = arguments.indexOf(' --', valueStart) - if (valueEnd < 0) throw new GradleException("ForgeGradle generated an unterminated --${flag} path") - String value = arguments.substring(valueStart, valueEnd) - if (!value.startsWith('"') || !value.endsWith('"')) { - arguments = "${arguments.substring(0, valueStart)}"${value}"${arguments.substring(valueEnd)}" - } - } - text = text.replace(programArguments[0][0], - programArguments[0][1] + arguments + programArguments[0][3]) - - String legacyPrefix = '-DlegacyClassPath.file=' - int legacyStart = text.indexOf(legacyPrefix) - int legacyEnd = text.indexOf(' -DignoreList=', legacyStart) - if (legacyStart < 0 || legacyEnd < 0) { - throw new GradleException("ForgeGradle omitted -DlegacyClassPath.file from ${launch.name}") - } - String xmlQuote = '"' - boolean quoted = legacyStart >= xmlQuote.length() - && text.substring(legacyStart - xmlQuote.length(), legacyStart) == xmlQuote - && text.substring(legacyEnd - xmlQuote.length(), legacyEnd) == xmlQuote - if (!quoted) { - text = text.substring(0, legacyStart) + xmlQuote + - text.substring(legacyStart, legacyEnd) + xmlQuote + text.substring(legacyEnd) - } - if (!text.contains('ATTR_EXCLUDE_TEST_CODE')) { - text = text.replace('', - ' \r\n' + - '') - } - launch.setText(text, 'UTF-8') - } - } -} - -def verifyEclipseProductionClasspath = tasks.register('verifyEclipseProductionClasspath') { - group = 'verification' - description = 'Verifies Eclipse client/server launches cannot see validation-only code.' - dependsOn syncForge40EclipseLaunches - doLast { - File classpath = file('.classpath') - if (!classpath.isFile()) throw new GradleException('Eclipse .classpath was not generated') - File eclipseProject = file('.project') - if (!eclipseProject.isFile() - || !eclipseProject.getText('UTF-8').contains("${eclipseWorkspaceProjectName}")) { - throw new GradleException("Eclipse project is not named ${eclipseWorkspaceProjectName}") - } - File buildshipPreferences = file('.settings/org.eclipse.buildship.core.prefs') - if (!buildshipPreferences.isFile()) { - throw new GradleException('Missing Eclipse Buildship preferences') - } - Properties buildshipConfiguration = new Properties() - buildshipPreferences.withInputStream { buildshipConfiguration.load(it) } - String expectedGradleHome = gradle.gradleUserHomeDir.canonicalPath - String expectedJavaHome = new File( - System.getenv('JAVA_HOME') ?: System.getProperty('java.home')).canonicalPath - if (buildshipConfiguration.getProperty('connection.gradle.user.home') != expectedGradleHome - || buildshipConfiguration.getProperty('gradle.user.home') != expectedGradleHome - || buildshipConfiguration.getProperty('java.home') != expectedJavaHome - || buildshipConfiguration.getProperty('override.workspace.settings') != 'true') { - throw new GradleException( - "Eclipse Buildship must use Gradle home ${expectedGradleHome} and Java ${expectedJavaHome}") - } - def classpathXml = new XmlSlurper(false, false).parse(classpath) - classpathXml.classpathentry.each { entry -> - String path = entry.@path.text().replace('\\', '/') - boolean validationSource = path.startsWith('src/test/') || path.startsWith('src/gametest/') - || path.startsWith('src/integratedSmoke/') || path.startsWith('src/packagedClientSmoke/') - || path.startsWith('src/upgradeSmoke/') - || path.startsWith('src/packagedServerSmoke/') - if (validationSource) { - String testFlag = entry.attributes.attribute.find { it.@name.text() == 'test' }?.@value?.text() - if (testFlag != 'true') { - throw new GradleException("Eclipse validation source is not isolated: ${path}") - } - } - } - ['runClient.launch', 'runServer.launch'].each { name -> - File launchFile = file(name) - String launch = launchFile.getText('UTF-8') - def launchXml = new XmlSlurper(false, false).parse(launchFile) - String mainType = launchXml.stringAttribute.find { - it.@key.text() == 'org.eclipse.jdt.launching.MAIN_TYPE' - }?.@value?.text() - if (mainType != 'net.minecraftforge.launcher.Main') { - throw new GradleException("Eclipse launch has the wrong ForgeGradle 7 main class: ${name}") - } - String mainClassEntry = mainType.replace('.', '/') + '.class' - List explicitArchives = launchXml.listAttribute.find { - it.@key.text() == 'org.eclipse.jdt.launching.CLASSPATH' - }?.listEntry?.collect { entry -> - def runtimeEntry = new XmlSlurper(false, false).parseText(entry.@value.text()) - String archive = runtimeEntry.@externalArchive.text() - archive ? file(archive) : null - }?.findAll { it != null } ?: [] - boolean mainClassAvailable = explicitArchives.any { archive -> - archive.isFile() && new java.util.zip.ZipFile(archive).withCloseable { zip -> - zip.getEntry(mainClassEntry) != null - } - } - if (!mainClassAvailable) { - throw new GradleException( - "Eclipse launch main class ${mainType} is absent from its explicit classpath: ${name}") - } - if (!launch.contains('ATTR_EXCLUDE_TEST_CODE') - || launch =~ /(?i)(junit|mockito|src[\\\/]test|src[\\\/]gametest|integratedsmoke|packagedclientsmoke|upgradesmoke|packagedserversmoke)/) { - throw new GradleException("Eclipse launch is not production-only: ${name}") - } - if (!launch.contains("key=\"org.eclipse.jdt.launching.PROJECT_ATTR\" value=\"${eclipseWorkspaceProjectName}\"")) { - throw new GradleException("Eclipse launch targets the wrong workspace project: ${name}") - } - String expectedModClasses = "${mod_id}%%${new File(projectDir, 'bin/main').absolutePath}" - if (!launch.contains(expectedModClasses)) { - throw new GradleException("Eclipse launch is not using Forge 40's merged production output: ${name}") - } - ['cache', 'metadata', 'to-srg', 'to-obf'].each { flag -> - if (!launch.contains("--${flag} "")) { - throw new GradleException("Eclipse launch does not quote its --${flag} path: ${name}") - } - } - if (!launch.contains('"-DlegacyClassPath.file=') - || !launch.contains('minecraft_classpath.txt" -DignoreList=')) { - throw new GradleException("Eclipse launch does not quote -DlegacyClassPath.file: ${name}") - } - } - File processed = layout.buildDirectory.dir('resources/main').get().asFile - ['META-INF/mods.toml', 'data/basemetals/orespawn/provider.json', - 'data/basemetals/recipes/stone_crackhammer.json'].each { name -> - File expected = new File(processed, name) - File actual = new File(projectDir, "bin/main/${name}") - if (!expected.isFile() || !actual.isFile() - || !java.util.Arrays.equals(actual.bytes, expected.bytes)) { - throw new GradleException("Eclipse output contains stale production resource ${name}") - } - } - String metadata = new File(projectDir, 'bin/main/META-INF/mods.toml').getText('UTF-8') - if (metadata.contains('${') || !metadata.contains('version="3.0.1.118021"')) { - throw new GradleException('Eclipse output contains unresolved or invalid Base Metals version metadata') - } - } -} - -tasks.matching { it.name == 'genEclipseRuns' }.configureEach { - finalizedBy syncForge40EclipseLaunches -} - -tasks.register('isolateEclipseProductionRuns') { - group = 'ide' - dependsOn tasks.named('genEclipseRuns') - dependsOn syncForge40EclipseLaunches -} - -tasks.matching { it.name == 'eclipse' }.configureEach { - finalizedBy syncForge40EclipseLaunches -} +// Keep the top-level build readable; each script owns one build concern. +apply from: 'gradle/dependencies.gradle' +apply from: 'gradle/release/artifacts.gradle' +apply from: 'gradle/release/publishing.gradle' +apply from: 'gradle/verification/support.gradle' +apply from: 'gradle/verification/packaged-runtime.gradle' +apply from: 'gradle/ide/eclipse.gradle' diff --git a/gradle/README.md b/gradle/README.md new file mode 100644 index 00000000..b97a815b --- /dev/null +++ b/gradle/README.md @@ -0,0 +1,18 @@ +# Gradle build layout + +The top-level `build.gradle` contains the shared project identity, Java +toolchain, source sets, and Forge development runs. The applied scripts keep +the remaining build concerns small enough to review independently. + +- `dependencies.gradle` owns repositories and dependency scopes. +- `release/artifacts.gradle` builds and audits deterministic release files. +- `release/publishing.gradle` describes guarded Maven publication. +- `verification/support.gradle` owns data, GameTest, and upgrade smoke gates. +- `verification/packaged-runtime.gradle` runs the built mod in official + packaged client and dedicated-server installations. +- `ide/eclipse.gradle` normalizes ForgeGradle's Eclipse launches and checks + that production runs cannot see validation-only code. + +Applied-script order is intentional. Artifact setup publishes an immutable +`baseMetalsRelease` map consumed by publishing and packaged-runtime checks. +Keep machine-specific paths in Gradle properties, never in these scripts. diff --git a/gradle/dependencies.gradle b/gradle/dependencies.gradle new file mode 100644 index 00000000..aadc125e --- /dev/null +++ b/gradle/dependencies.gradle @@ -0,0 +1,81 @@ +ext.orespawnModule = "mmd-orespawn-${project.orespawn_curse_project_id}" +def orespawnVerificationRepository = project.findProperty('orespawnVerificationRepository') +repositories { + minecraft.mavenizer(it) + maven fg.forgeMaven + maven fg.minecraftLibsMaven + mavenCentral() + + // CI stages and hashes the exact public OreSpawn jar in a temporary Maven + // mirror. Normal development resolves the same coordinates from CurseMaven. + exclusiveContent { + forRepository { + maven { + name = orespawnVerificationRepository == null + ? 'CurseMaven' + : 'OreSpawnReleaseVerificationMirror' + url = orespawnVerificationRepository == null + ? uri('https://www.cursemaven.com') + : uri(orespawnVerificationRepository) + metadataSources { artifact() } + } + } + filter { + includeModule('curse.maven', orespawnModule) + } + } + + flatDir { + dir rootProject.file('../../benchmarks/external/EnderIO-1.18.2/build/libs') + } + maven { url = uri('https://modmaven.dev/') } + maven { url = uri('https://dvs1.progwml6.com/files/maven/') } + maven { url = uri('https://maven.covers1624.net/') } +} + +dependencies { + // ForgeGradle supplies the patched Minecraft development environment. + implementation minecraft.dependency("net.minecraftforge:forge:${minecraft_version}-${forge_version}") + + // OreSpawn remains an ordinary hard mod dependency in metadata and is + // never shaded into Base Metals. Use the published 1.18.2 release in + // development so clean checkouts exercise the same artifact as players. + runtimeOnly renamer.dependency("curse.maven:mmd-orespawn-${orespawn_curse_project_id}:${orespawn_curse_file_id}") + orespawnRelease "curse.maven:mmd-orespawn-${orespawn_curse_project_id}:${orespawn_curse_file_id}" + + // API only: Mekanism remains optional at runtime. Its public slurry + // registry is required to provide the eight historical processing chains. + compileOnly 'mekanism:Mekanism:1.18.2-10.2.5.465:api' + + // Opt-in runtime distributions for compatibility smoke tests. These are + // never packaged, required, or resolved by ordinary Base Metals builds. + def compatibilitySmokes = providers.gradleProperty('compatSmoke').getOrElse('') + .split(',').collect { it.trim().toLowerCase(Locale.ROOT) }.findAll { !it.isEmpty() } + if (compatibilitySmokes.contains('mekanism') || compatibilitySmokes.contains('all')) { + runtimeOnly renamer.dependency('mekanism:Mekanism:1.18.2-10.2.5.465') + } + if (compatibilitySmokes.contains('tconstruct') || compatibilitySmokes.contains('all')) { + runtimeOnly renamer.dependency('slimeknights.mantle:Mantle:1.18.2-1.9.55') + runtimeOnly renamer.dependency('slimeknights.tconstruct:TConstruct:1.18.2-3.7.2.169') + } + if (compatibilitySmokes.contains('thermal') || compatibilitySmokes.contains('all')) { + runtimeOnly renamer.dependency('com.teamcofh:cofh_core:1.18.2-9.2.3.47') + runtimeOnly renamer.dependency('com.teamcofh:thermal_foundation:1.18.2-9.2.2.59') + runtimeOnly renamer.dependency('com.teamcofh:thermal_expansion:1.18.2-9.2.2.24') + } + if (compatibilitySmokes.contains('enderio') || compatibilitySmokes.contains('all')) { + runtimeOnly renamer.dependency('validation:EnderIO-base-1.18.2:6.0.0-alpha-a94a2cb') + runtimeOnly renamer.dependency('validation:EnderIO-machines-1.18.2:6.0.0-alpha-a94a2cb') + } + + testImplementation platform('org.junit:junit-bom:5.10.2') + testImplementation 'org.junit.jupiter:junit-jupiter' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher:1.10.2' + +} + +renamer.mappings.from minecraft.dependency.toSrgFile + +configurations.named('testRuntimeClasspath') { + exclude group: 'curse.maven', module: orespawnModule +} diff --git a/gradle/ide/eclipse.gradle b/gradle/ide/eclipse.gradle new file mode 100644 index 00000000..933b65f5 --- /dev/null +++ b/gradle/ide/eclipse.gradle @@ -0,0 +1,251 @@ +import groovy.xml.XmlSlurper + +def eclipseWorkspaceProjectName = projectDir.name + +// ForgeGradle 7 Eclipse launches are normalized after generation: they use the +// configured Gradle cache and Java 17, load only bin/main, and exclude all unit, +// GameTest, integrated-smoke, and upgrade-smoke code from production launches. +eclipse { + classpath { + downloadSources = true + downloadJavadoc = true + file.whenMerged { classpath -> + classpath.entries.findAll { entry -> + String path = entry.path?.replace('\\', '/') ?: '' + path.startsWith('src/test/') || path.startsWith('src/gametest/') + || path.startsWith('src/integratedSmoke/') || path.startsWith('src/packagedClientSmoke/') + || path.startsWith('src/upgradeSmoke/') + || path.startsWith('src/packagedServerSmoke/') + }.each { entry -> + entry.entryAttributes['test'] = 'true' + } + } + } + synchronizationTasks 'isolateEclipseProductionRuns' +} + +idea { + module { + downloadSources = true + downloadJavadoc = true + } +} + +def syncForge40EclipseLaunches = tasks.register('syncForge40EclipseLaunches') { + group = 'ide' + description = 'Normalizes Forge 40 Eclipse launches to the production-only merged output.' + dependsOn tasks.named('processResources') + doLast { + String mainOutput = new File(projectDir, 'bin/main').absolutePath + String modClasses = "${mod_id}%%${mainOutput}" + String eclipseGradleJavaHome = System.getenv('JAVA_HOME') ?: System.getProperty('java.home') + def escapePreference = { String value -> value.replace('\\', '\\\\').replace(':', '\\:') } + File buildshipPreferences = file('.settings/org.eclipse.buildship.core.prefs') + if (!buildshipPreferences.parentFile.isDirectory() && !buildshipPreferences.parentFile.mkdirs()) { + throw new GradleException("Could not create ${buildshipPreferences.parentFile}") + } + buildshipPreferences.setText([ + 'arguments=', + 'auto.sync=false', + 'build.scans.enabled=false', + 'connection.gradle.distribution=GRADLE_DISTRIBUTION(WRAPPER)', + "connection.gradle.user.home=${escapePreference(gradle.gradleUserHomeDir.absolutePath)}", + 'connection.project.dir=', + 'eclipse.preferences.version=1', + "gradle.user.home=${escapePreference(gradle.gradleUserHomeDir.absolutePath)}", + "java.home=${escapePreference(eclipseGradleJavaHome)}", + 'jvm.arguments=', + 'offline.mode=false', + 'override.workspace.settings=true', + 'show.console.view=false', + 'show.executions.view=false', + '' + ].join('\r\n'), 'UTF-8') + + File processedResources = layout.buildDirectory.dir('resources/main').get().asFile + copy { + from processedResources + into mainOutput + } + ['Client', 'Server'].each { String runName -> + File launch = file("run${runName}.launch") + if (!launch.isFile()) throw new GradleException("Missing generated Eclipse launch ${launch}") + String text = launch.getText('UTF-8') + if (text.contains('')) { + text = text.replace( + '', + '\r\n' + + " \r\n" + + '') + } + text = text.replaceFirst( + //, + java.util.regex.Matcher.quoteReplacement( + "")) + text = text.replaceFirst(//, + java.util.regex.Matcher.quoteReplacement("")) + + def programArguments = text =~ /()/ + if (programArguments.count != 1) { + throw new GradleException("ForgeGradle generated malformed program arguments in ${launch.name}") + } + String arguments = programArguments[0][2] + ['cache', 'metadata', 'to-srg', 'to-obf'].each { flag -> + String prefix = "--${flag} " + int valueStart = arguments.indexOf(prefix) + if (valueStart < 0) throw new GradleException("ForgeGradle omitted --${flag} from ${launch.name}") + valueStart += prefix.length() + int valueEnd = arguments.indexOf(' --', valueStart) + if (valueEnd < 0) throw new GradleException("ForgeGradle generated an unterminated --${flag} path") + String value = arguments.substring(valueStart, valueEnd) + if (!value.startsWith('"') || !value.endsWith('"')) { + arguments = "${arguments.substring(0, valueStart)}"${value}"${arguments.substring(valueEnd)}" + } + } + text = text.replace(programArguments[0][0], + programArguments[0][1] + arguments + programArguments[0][3]) + + String legacyPrefix = '-DlegacyClassPath.file=' + int legacyStart = text.indexOf(legacyPrefix) + int legacyEnd = text.indexOf(' -DignoreList=', legacyStart) + if (legacyStart < 0 || legacyEnd < 0) { + throw new GradleException("ForgeGradle omitted -DlegacyClassPath.file from ${launch.name}") + } + String xmlQuote = '"' + boolean quoted = legacyStart >= xmlQuote.length() + && text.substring(legacyStart - xmlQuote.length(), legacyStart) == xmlQuote + && text.substring(legacyEnd - xmlQuote.length(), legacyEnd) == xmlQuote + if (!quoted) { + text = text.substring(0, legacyStart) + xmlQuote + + text.substring(legacyStart, legacyEnd) + xmlQuote + text.substring(legacyEnd) + } + if (!text.contains('ATTR_EXCLUDE_TEST_CODE')) { + text = text.replace('', + ' \r\n' + + '') + } + launch.setText(text, 'UTF-8') + } + } +} + +def verifyEclipseProductionClasspath = tasks.register('verifyEclipseProductionClasspath') { + group = 'verification' + description = 'Verifies Eclipse client/server launches cannot see validation-only code.' + dependsOn syncForge40EclipseLaunches + doLast { + File classpath = file('.classpath') + if (!classpath.isFile()) throw new GradleException('Eclipse .classpath was not generated') + File eclipseProject = file('.project') + if (!eclipseProject.isFile() + || !eclipseProject.getText('UTF-8').contains("${eclipseWorkspaceProjectName}")) { + throw new GradleException("Eclipse project is not named ${eclipseWorkspaceProjectName}") + } + File buildshipPreferences = file('.settings/org.eclipse.buildship.core.prefs') + if (!buildshipPreferences.isFile()) { + throw new GradleException('Missing Eclipse Buildship preferences') + } + Properties buildshipConfiguration = new Properties() + buildshipPreferences.withInputStream { buildshipConfiguration.load(it) } + String expectedGradleHome = gradle.gradleUserHomeDir.canonicalPath + String expectedJavaHome = new File( + System.getenv('JAVA_HOME') ?: System.getProperty('java.home')).canonicalPath + if (buildshipConfiguration.getProperty('connection.gradle.user.home') != expectedGradleHome + || buildshipConfiguration.getProperty('gradle.user.home') != expectedGradleHome + || buildshipConfiguration.getProperty('java.home') != expectedJavaHome + || buildshipConfiguration.getProperty('override.workspace.settings') != 'true') { + throw new GradleException( + "Eclipse Buildship must use Gradle home ${expectedGradleHome} and Java ${expectedJavaHome}") + } + def classpathXml = new XmlSlurper(false, false).parse(classpath) + classpathXml.classpathentry.each { entry -> + String path = entry.@path.text().replace('\\', '/') + boolean validationSource = path.startsWith('src/test/') || path.startsWith('src/gametest/') + || path.startsWith('src/integratedSmoke/') || path.startsWith('src/packagedClientSmoke/') + || path.startsWith('src/upgradeSmoke/') + || path.startsWith('src/packagedServerSmoke/') + if (validationSource) { + String testFlag = entry.attributes.attribute.find { it.@name.text() == 'test' }?.@value?.text() + if (testFlag != 'true') { + throw new GradleException("Eclipse validation source is not isolated: ${path}") + } + } + } + ['runClient.launch', 'runServer.launch'].each { name -> + File launchFile = file(name) + String launch = launchFile.getText('UTF-8') + def launchXml = new XmlSlurper(false, false).parse(launchFile) + String mainType = launchXml.stringAttribute.find { + it.@key.text() == 'org.eclipse.jdt.launching.MAIN_TYPE' + }?.@value?.text() + if (mainType != 'net.minecraftforge.launcher.Main') { + throw new GradleException("Eclipse launch has the wrong ForgeGradle 7 main class: ${name}") + } + String mainClassEntry = mainType.replace('.', '/') + '.class' + List explicitArchives = launchXml.listAttribute.find { + it.@key.text() == 'org.eclipse.jdt.launching.CLASSPATH' + }?.listEntry?.collect { entry -> + def runtimeEntry = new XmlSlurper(false, false).parseText(entry.@value.text()) + String archive = runtimeEntry.@externalArchive.text() + archive ? file(archive) : null + }?.findAll { it != null } ?: [] + boolean mainClassAvailable = explicitArchives.any { archive -> + archive.isFile() && new java.util.zip.ZipFile(archive).withCloseable { zip -> + zip.getEntry(mainClassEntry) != null + } + } + if (!mainClassAvailable) { + throw new GradleException( + "Eclipse launch main class ${mainType} is absent from its explicit classpath: ${name}") + } + if (!launch.contains('ATTR_EXCLUDE_TEST_CODE') + || launch =~ /(?i)(junit|mockito|src[\\\/]test|src[\\\/]gametest|integratedsmoke|packagedclientsmoke|upgradesmoke|packagedserversmoke)/) { + throw new GradleException("Eclipse launch is not production-only: ${name}") + } + if (!launch.contains("key=\"org.eclipse.jdt.launching.PROJECT_ATTR\" value=\"${eclipseWorkspaceProjectName}\"")) { + throw new GradleException("Eclipse launch targets the wrong workspace project: ${name}") + } + String expectedModClasses = "${mod_id}%%${new File(projectDir, 'bin/main').absolutePath}" + if (!launch.contains(expectedModClasses)) { + throw new GradleException("Eclipse launch is not using Forge 40's merged production output: ${name}") + } + ['cache', 'metadata', 'to-srg', 'to-obf'].each { flag -> + if (!launch.contains("--${flag} "")) { + throw new GradleException("Eclipse launch does not quote its --${flag} path: ${name}") + } + } + if (!launch.contains('"-DlegacyClassPath.file=') + || !launch.contains('minecraft_classpath.txt" -DignoreList=')) { + throw new GradleException("Eclipse launch does not quote -DlegacyClassPath.file: ${name}") + } + } + File processed = layout.buildDirectory.dir('resources/main').get().asFile + ['META-INF/mods.toml', 'data/basemetals/orespawn/provider.json', + 'data/basemetals/recipes/stone_crackhammer.json'].each { name -> + File expected = new File(processed, name) + File actual = new File(projectDir, "bin/main/${name}") + if (!expected.isFile() || !actual.isFile() + || !java.util.Arrays.equals(actual.bytes, expected.bytes)) { + throw new GradleException("Eclipse output contains stale production resource ${name}") + } + } + String metadata = new File(projectDir, 'bin/main/META-INF/mods.toml').getText('UTF-8') + if (metadata.contains('${') || !metadata.contains('version="3.0.1.118021"')) { + throw new GradleException('Eclipse output contains unresolved or invalid Base Metals version metadata') + } + } +} + +tasks.matching { it.name == 'genEclipseRuns' }.configureEach { + finalizedBy syncForge40EclipseLaunches +} + +tasks.register('isolateEclipseProductionRuns') { + group = 'ide' + dependsOn tasks.named('genEclipseRuns') + dependsOn syncForge40EclipseLaunches +} + +tasks.matching { it.name == 'eclipse' }.configureEach { + finalizedBy syncForge40EclipseLaunches +} diff --git a/gradle/release/artifacts.gradle b/gradle/release/artifacts.gradle new file mode 100644 index 00000000..272c4964 --- /dev/null +++ b/gradle/release/artifacts.gradle @@ -0,0 +1,377 @@ +import groovy.json.JsonSlurper +import groovy.xml.XmlSlurper +import java.security.MessageDigest +import java.util.zip.ZipFile + +// Expand the release identity into Forge metadata and package public documentation. +tasks.named('processResources', ProcessResources).configure { + var replaceProperties = [ + minecraft_version: minecraft_version, minecraft_version_range: minecraft_version_range, + forge_version: forge_version, forge_version_range: forge_version_range, + loader_version_range: loader_version_range, + mod_id: mod_id, mod_name: mod_name, mod_license: mod_license, mod_version: mod_version, + mod_authors: mod_authors, mod_description: mod_description, + ] + inputs.properties replaceProperties + + filesMatching(['META-INF/mods.toml', 'pack.mcmeta']) { + expand replaceProperties + [project: project] + } + + from('docs') { + into 'META-INF/basemetals/docs' + } +} + +// Eclipse launches use this processed directory as a stable metadata/resource +// root. This prevents a transient Gradle resolution failure immediately before +// launch from starting Forge with Java classes but no Base Metals data pack. +tasks.matching { it.name in ['genEclipseRuns', 'eclipse'] }.configureEach { + dependsOn tasks.named('processResources') +} + +def expectedMavenGroup = 'zone.moddev.mc.basemetals' +def expectedMavenArtifact = 'BaseMetals' +def expectedMavenCoordinate = "${expectedMavenGroup}:${expectedMavenArtifact}:${project.version}" + +tasks.named('jar', Jar) { + archiveClassifier = 'deobf' + destinationDirectory = layout.buildDirectory.dir('libs-dev') + manifest { + attributes([ + 'Maven-Artifact' : expectedMavenCoordinate, + 'Specification-Title' : 'Base Metals', + 'Specification-Vendor' : project.mod_authors, + 'Specification-Version' : '1', + 'Implementation-Title' : 'Base Metals', + 'Implementation-Version': project.version, + 'Implementation-Vendor' : project.mod_authors, + 'Built-On-Java' : '17', + 'Built-On' : "${project.minecraft_version}-${project.forge_version}", + 'Build-Target' : "minecraft-${project.minecraft_version}-${project.loader_name}-${project.forge_version}-java-${project.java_version}" + ]) + } +} + +def releaseJar = renamer.classes(tasks.named('jar', Jar)) { + map.from minecraft.dependency.toSrgFile + archiveClassifier = null + output = layout.buildDirectory.file( + "libs/${base.archivesName.get()}-${project.version}.jar") +} + +['apiElements', 'runtimeElements'].each { configurationName -> + configurations.named(configurationName) { artifacts.clear() } + artifacts { add(configurationName, releaseJar) } +} + +tasks.named('assemble') { + dependsOn releaseJar + dependsOn tasks.named('sourcesJar') + dependsOn tasks.named('javadocJar') +} + +def mainReleaseName = "BaseMetals-${project.version}.jar" +def sourcesReleaseName = "BaseMetals-${project.version}-sources.jar" +def javadocReleaseName = "BaseMetals-${project.version}-javadoc.jar" +def expectedReleaseNames = [mainReleaseName, sourcesReleaseName, javadocReleaseName] + +def packagedServerSmokeJar = tasks.register('packagedServerSmokeJar', Jar) { + dependsOn tasks.named(sourceSets.packagedServerSmoke.classesTaskName) + archiveFileName = 'basemetals-server-smoke-dev.jar' + destinationDirectory = layout.buildDirectory.dir('packaged-smoke-probes') + from sourceSets.packagedServerSmoke.output.classesDirs + from 'src/packagedServerSmoke/packaged-resources' +} +def packagedServerSmokeProbe = renamer.classes(packagedServerSmokeJar) { + map.from minecraft.dependency.toSrgFile + output = layout.buildDirectory.file('packaged-smoke-probes/basemetals-server-smoke.jar') +} + +def packagedClientSmokeJar = tasks.register('packagedClientSmokeJar', Jar) { + dependsOn tasks.named(sourceSets.integratedSmoke.classesTaskName) + dependsOn tasks.named(sourceSets.packagedClientSmoke.classesTaskName) + archiveFileName = 'basemetals-client-smoke-dev.jar' + destinationDirectory = layout.buildDirectory.dir('packaged-smoke-probes') + from sourceSets.integratedSmoke.output.classesDirs + from sourceSets.packagedClientSmoke.output.classesDirs + from 'src/packagedClientSmoke/packaged-resources' +} +def packagedClientSmokeProbe = renamer.classes(packagedClientSmokeJar) { + map.from minecraft.dependency.toSrgFile + output = layout.buildDirectory.file('packaged-smoke-probes/basemetals-client-smoke.jar') +} + +def preparedReleaseDir = project.findProperty('preparedReleaseDir') +project.ext.baseMetalsRelease = [ + expectedMavenGroup: expectedMavenGroup, + expectedMavenArtifact: expectedMavenArtifact, + expectedMavenCoordinate: expectedMavenCoordinate, + releaseJar: releaseJar, + mainReleaseName: mainReleaseName, + sourcesReleaseName: sourcesReleaseName, + javadocReleaseName: javadocReleaseName, + expectedReleaseNames: expectedReleaseNames.asImmutable(), + packagedServerSmokeProbe: packagedServerSmokeProbe, + packagedClientSmokeProbe: packagedClientSmokeProbe, + preparedReleaseDir: preparedReleaseDir +].asImmutable() + +def sha256 = { File artifact -> + MessageDigest.getInstance('SHA-256').digest(artifact.bytes).encodeHex().toString().toUpperCase() +} + +def configuredReleaseVersion = project.version.toString() +def configuredReleaseGroup = project.group.toString() +def configuredJavaToolchainVersion = project.java_toolchain_version.toString() +def configuredReleaseIdentity = [ + modVersion: project.mod_version.toString(), + modGroup: project.mod_group.toString(), + minecraftVersion: project.minecraft_version.toString(), + forgeVersion: project.forge_version.toString(), + forgeInstallerSha256: project.forge_installer_sha256.toString(), + mappingChannel: project.mapping_channel.toString(), + mappingVersion: project.mapping_version.toString(), + loaderName: project.loader_name.toString(), + loaderCode: project.loader_code.toString(), + javaVersion: project.java_version.toString(), + gradleJavaVersion: project.gradle_java_version.toString(), + curseForgeProjectId: project.curseforge_project_id.toString() +] +def configuredOreSpawnIdentity = [ + version: project.orespawn_version.toString(), + curseProjectId: project.orespawn_curse_project_id.toString(), + curseFileId: project.orespawn_curse_file_id.toString(), + sha256: project.orespawn_sha256.toString() +] + +tasks.register('verifyJava17Toolchain') { + group = 'verification' + description = 'Verifies the exact Java product toolchain used for Base Metals compilation.' + dependsOn tasks.named('compileJava') + doLast { + def metadata = tasks.named('compileJava', JavaCompile).get().javaCompiler.get().metadata + if (configuredJavaToolchainVersion != '17.0.1+12' + || metadata.languageVersion.asInt() != 17 + || metadata.jvmVersion != configuredJavaToolchainVersion + || !metadata.vendor.toString().toLowerCase(Locale.ROOT).contains('temurin')) { + throw new GradleException("Expected Eclipse Temurin ${configuredJavaToolchainVersion}, found " + + "${metadata.vendor} ${metadata.jvmVersion} at ${metadata.installationPath}") + } + } +} + +tasks.register('verifyMavenCoordinates') { + group = 'verification' + description = 'Verifies the generated POM uses the Base Metals release namespace.' + dependsOn 'generatePomFileForMavenJavaPublication' + doLast { + File pomFile = file("${buildDir}/publications/mavenJava/pom-default.xml") + if (!pomFile.isFile()) throw new GradleException("Generated Maven POM does not exist: ${pomFile}") + def pom = new XmlSlurper(false, false).parse(pomFile) + def actual = [pom.groupId.text(), pom.artifactId.text(), pom.version.text()] + def expected = [expectedMavenGroup, expectedMavenArtifact, configuredReleaseVersion] + if (configuredReleaseGroup != expectedMavenGroup || actual != expected) { + throw new GradleException("Expected Maven coordinate ${expected.join(':')}, found ${actual.join(':')}") + } + } +} + +tasks.register('verifyMavenizerCompatibilityFixture') { + group = 'verification' + description = 'Verifies the checksum-sealed Forge 1.18.2 Mavenizer compatibility fixture.' + inputs.file project.ext.mavenizerCompatibilityJar + doLast { + File fixture = project.ext.mavenizerCompatibilityJar.asFile + if (!fixture.isFile()) throw new GradleException("Missing Mavenizer compatibility fixture: ${fixture}") + String actual = sha256(fixture) + if (actual != project.ext.mavenizerCompatibilitySha256) { + throw new GradleException("Mavenizer compatibility fixture checksum mismatch: ${actual}") + } + } +} + +tasks.register('verifyReleaseConfiguration') { + group = 'verification' + description = 'Verifies the exact Base Metals 1.18.2 release identity.' + dependsOn tasks.named('verifyMavenizerCompatibilityFixture') + dependsOn tasks.named('verifyJava17Toolchain') + doLast { + if (configuredReleaseIdentity.modVersion != '3.0.1.118021' + || configuredReleaseIdentity.modGroup != expectedMavenGroup + || configuredReleaseIdentity.minecraftVersion != '1.18.2' + || configuredReleaseIdentity.forgeVersion != '40.3.0' + || configuredReleaseIdentity.forgeInstallerSha256 != '9434C29790504DCD11CE97CB30DD8891B4B18848982357BC1B5ABFA79FB95103' + || configuredReleaseIdentity.mappingChannel != 'official' + || configuredReleaseIdentity.mappingVersion != '1.18.2' + || configuredReleaseIdentity.loaderName != 'forge' + || configuredReleaseIdentity.loaderCode != '1' + || configuredReleaseIdentity.javaVersion != '17' + || configuredReleaseIdentity.gradleJavaVersion != '17' + || configuredReleaseIdentity.curseForgeProjectId != '240967') { + throw new GradleException('Unexpected Base Metals 1.18.2 release identity') + } + } +} + +tasks.register('verifyReleaseDependencies') { + group = 'verification' + description = 'Resolves and verifies the exact released OreSpawn qualification dependency.' + doLast { + if (configuredOreSpawnIdentity.version != '4.0.16.118021' + || configuredOreSpawnIdentity.curseProjectId != '245586' + || configuredOreSpawnIdentity.curseFileId != '8780769' + || configuredOreSpawnIdentity.sha256 != '76FD6140FE85C3D9EEA0321A0441D352A0B2643390A1090BBB1F70FE104C5717') { + throw new GradleException('Unexpected OreSpawn release coordinates') + } + File artifact = configurations.orespawnRelease.resolvedConfiguration.resolvedArtifacts + .find { it.moduleVersion.id.group == 'curse.maven' && it.file.name.endsWith('.jar') }?.file + if (artifact == null || !artifact.isFile()) { + throw new GradleException('Released OreSpawn artifact did not resolve') + } + String actual = sha256(artifact) + if (actual != configuredOreSpawnIdentity.sha256) { + throw new GradleException("OreSpawn SHA-256 mismatch: ${actual}") + } + } +} + +def forbiddenReleasePath = { String entryName -> + String name = entryName.replace('\\', '/').toLowerCase(Locale.ROOT) + name.startsWith('com/mcmoddev/') + || name.startsWith('ci-fixtures/') + || name.startsWith('reference/') + || name.startsWith('agent-notes/') + || name.contains('/agent-notes/') + || name.startsWith('src/gametest/') + || name.startsWith('src/integratedsmoke/') + || name.startsWith('src/packagedclientsmoke/') + || name.startsWith('src/upgradesmoke/') + || name.startsWith('src/packagedserversmoke/') + || name.contains('minecraft-mavenizer') + || name.contains('gametests.class') + || name.contains('/smoke/') + || name.endsWith('/oregenerator.class') + || name.endsWith('/worldgenerator.class') +} + +tasks.register('verifyReleaseArtifacts') { + group = 'verification' + description = 'Audits the deterministic release jars and their production-only contents.' + dependsOn tasks.named('verifyReleaseConfiguration') + dependsOn tasks.named('verifyReleaseDependencies') + dependsOn tasks.named('verifyMavenCoordinates') + dependsOn tasks.named('assemble') + doLast { + File libs = file("${buildDir}/libs") + def actual = fileTree(libs) { include 'BaseMetals-*.jar' }.files.collect { it.name }.sort() + def expected = expectedReleaseNames.sort() + if (actual != expected) { + throw new GradleException("Expected exactly ${expected}, found ${actual}") + } + expectedReleaseNames.each { artifactName -> + new ZipFile(new File(libs, artifactName)).withCloseable { zip -> + def leaked = zip.entries().toList().find { forbiddenReleasePath(it.name) } + if (leaked != null) { + throw new GradleException("${artifactName} contains validation, fixture, legacy, or agent-only path ${leaked.name}") + } + } + } + + File main = new File(libs, mainReleaseName) + new ZipFile(main).withCloseable { zip -> + def names = zip.entries().toList().collect { it.name } as Set + [ + 'META-INF/mods.toml', + 'META-INF/MANIFEST.MF', + 'pack.mcmeta', + 'data/basemetals/orespawn/provider.json', + 'data/basemetals/recipes/stone_crackhammer.json', + 'data/forge/loot_modifiers/global_loot_modifiers.json', + 'assets/basemetals/lang/en_us.json', + 'META-INF/basemetals/docs/VERSIONS.md', + 'META-INF/basemetals/docs/schemas/orespawn-4.0.16/orespawn-provider.schema.json' + ].each { required -> + if (!names.contains(required)) throw new GradleException("Release jar is missing ${required}") + } + def manifest = new java.util.jar.Manifest(zip.getInputStream(zip.getEntry('META-INF/MANIFEST.MF'))) + if (manifest.mainAttributes.getValue('Maven-Artifact') != expectedMavenCoordinate) { + throw new GradleException('Release manifest contains the wrong Maven coordinate') + } + String expectedBuildTarget = 'minecraft-1.18.2-forge-40.3.0-java-17' + if (manifest.mainAttributes.getValue('Build-Target') != expectedBuildTarget) { + throw new GradleException('Release manifest contains the wrong stable build target') + } + String modsToml = zip.getInputStream(zip.getEntry('META-INF/mods.toml')).getText('UTF-8') + if (modsToml.contains('${') + || !modsToml.contains('version="3.0.1.118021"') + || !modsToml.contains('versionRange="[4.0.1,5.0.0)"')) { + throw new GradleException('Release mods.toml contains unresolved or incompatible metadata') + } + def provider = new JsonSlurper().parse(zip.getInputStream( + zip.getEntry('data/basemetals/orespawn/provider.json'))) + if (provider.schema_version != 3 || provider.provider_revision != 1 + || provider.provider_modid != 'basemetals') { + throw new GradleException('OreSpawn provider schema/revision identity changed') + } + def locales = names.findAll { it ==~ /assets\/basemetals\/lang\/.+\.json/ } + if (locales.size() != 18) { + throw new GradleException("Release jar must contain all 18 language files, found ${locales.size()}") + } + if (names.any { it.endsWith('.class') && (it.contains('/gametest/') + || it.contains('/smoke/') || it.startsWith('com/mcmoddev/')) }) { + throw new GradleException('Release jar contains validation or legacy implementation classes') + } + } + } +} + +tasks.register('writeReleaseChecksums') { + group = 'verification' + description = 'Writes SHA-256 checksums for exactly the three release jars.' + dependsOn tasks.named('verifyReleaseArtifacts') + doLast { + File output = file("${buildDir}/release/SHA256SUMS") + output.parentFile.mkdirs() + output.setText(expectedReleaseNames.sort().collect { name -> + "${sha256(file("${buildDir}/libs/${name}")).toLowerCase(Locale.ROOT)} ${name}" + }.join('\n') + '\n', 'UTF-8') + } +} + +tasks.register('verifyPreparedReleaseArtifacts') { + group = 'verification' + description = 'Verifies the immutable artifact bundle supplied to Maven publication.' + inputs.files { + preparedReleaseDir == null ? [] : + (expectedReleaseNames + ['SHA256SUMS', 'CHANGELOG.txt']).collect { + new File(preparedReleaseDir.toString(), it) + } + } + doLast { + if (preparedReleaseDir == null) { + throw new GradleException('preparedReleaseDir is required for prepared artifact publication') + } + File prepared = file(preparedReleaseDir.toString()) + if (!prepared.isDirectory()) throw new GradleException("Prepared release directory does not exist: ${prepared}") + def expectedBundle = (expectedReleaseNames + ['SHA256SUMS', 'CHANGELOG.txt']).sort() + def actualBundle = prepared.listFiles().findAll { it.isFile() }.collect { it.name }.sort() + if (actualBundle != expectedBundle) { + throw new GradleException("Expected prepared bundle ${expectedBundle}, found ${actualBundle}") + } + def declared = [:] + new File(prepared, 'SHA256SUMS').readLines('UTF-8').findAll { !it.trim().isEmpty() }.each { line -> + def match = line =~ /^([0-9a-fA-F]{64}) (.+)$/ + if (!match.matches()) throw new GradleException("Invalid prepared checksum line: ${line}") + declared[match[0][2]] = match[0][1].toUpperCase(Locale.ROOT) + } + if (declared.keySet().sort() != expectedReleaseNames.sort()) { + throw new GradleException('Prepared checksums do not declare exactly the three release jars') + } + declared.each { name, expectedHash -> + if (sha256(new File(prepared, name)) != expectedHash) { + throw new GradleException("Prepared checksum mismatch for ${name}") + } + } + } +} diff --git a/gradle/release/publishing.gradle b/gradle/release/publishing.gradle new file mode 100644 index 00000000..cf269428 --- /dev/null +++ b/gradle/release/publishing.gradle @@ -0,0 +1,74 @@ +import org.gradle.api.publish.maven.MavenPublication +import org.gradle.api.publish.maven.tasks.PublishToMavenRepository + +def release = project.ext.baseMetalsRelease +def preparedReleaseDir = release.preparedReleaseDir +def mavenUploadUrl = System.getenv('MAVEN_UPLOAD_URL') ?: 'https://invalid.invalid/missing-maven-upload-url' +def mavenUploadUsername = System.getenv('MAVEN_UPLOAD_USERNAME') ?: '' +def mavenUploadPassword = System.getenv('MAVEN_UPLOAD_PASSWORD') ?: '' + +publishing { + publications { + mavenJava(MavenPublication) { + groupId = project.group + artifactId = release.expectedMavenArtifact + version = project.version + if (preparedReleaseDir != null) { + artifact new File(preparedReleaseDir.toString(), release.mainReleaseName) + artifact(new File(preparedReleaseDir.toString(), release.sourcesReleaseName)) { + classifier = 'sources' + } + artifact(new File(preparedReleaseDir.toString(), release.javadocReleaseName)) { + classifier = 'javadoc' + } + } else { + artifact release.releaseJar + artifact tasks.named('sourcesJar') + artifact tasks.named('javadocJar') + } + pom.withXml { + def root = asNode() + root.appendNode('name', 'Base Metals') + root.appendNode('description', + 'Historically inspired and fantasy metals, equipment, processing, and OreSpawn provider data.') + root.appendNode('url', 'https://github.com/MinecraftModDevelopmentMods/BaseMetals') + def license = root.appendNode('licenses').appendNode('license') + license.appendNode('name', 'GNU Lesser General Public License, Version 2.1') + license.appendNode('url', 'https://www.gnu.org/licenses/old-licenses/lgpl-2.1.txt') + } + } + } + repositories { + maven { + name = 'release' + url = uri(mavenUploadUrl) + credentials { + username = mavenUploadUsername + password = mavenUploadPassword + } + } + } +} + +tasks.register('validateMavenReleaseCredentials') { + group = 'publishing' + description = 'Prevents Maven publication from targeting an incomplete or local repository.' + doLast { + if (!System.getenv('MAVEN_UPLOAD_URL') + || !System.getenv('MAVEN_UPLOAD_USERNAME') + || !System.getenv('MAVEN_UPLOAD_PASSWORD')) { + throw new GradleException('MAVEN_UPLOAD_URL, MAVEN_UPLOAD_USERNAME, and MAVEN_UPLOAD_PASSWORD are required') + } + if (System.getenv('MAVEN_UPLOAD_URL').startsWith('file:')) { + throw new GradleException('Maven release publication must use a remote repository') + } + } +} + +tasks.withType(PublishToMavenRepository).configureEach { + dependsOn tasks.named('validateMavenReleaseCredentials') + dependsOn tasks.named('verifyMavenCoordinates') + dependsOn(preparedReleaseDir == null + ? tasks.named('verifyReleaseArtifacts') + : tasks.named('verifyPreparedReleaseArtifacts')) +} diff --git a/gradle/verification/packaged-runtime.gradle b/gradle/verification/packaged-runtime.gradle new file mode 100644 index 00000000..9f0ee263 --- /dev/null +++ b/gradle/verification/packaged-runtime.gradle @@ -0,0 +1,243 @@ +import groovy.json.JsonSlurper +import org.gradle.api.provider.Provider + +def release = project.ext.baseMetalsRelease +def releaseJar = release.releaseJar +def packagedServerSmokeProbe = release.packagedServerSmokeProbe +def packagedClientSmokeProbe = release.packagedClientSmokeProbe + +def java17Launcher = javaToolchains.launcherFor { + languageVersion = JavaLanguageVersion.of(17) + vendor = JvmVendorSpec.ADOPTIUM +} + +def packagedForgeServerRuntime = providers.gradleProperty('packagedForgeServerRuntime') +def packagedForgeClientRuntime = providers.gradleProperty('packagedForgeClientRuntime') +def packagedClientJavaExecutable = providers.gradleProperty('packagedClientJavaExecutable') +def packagedServerRunDirectory = layout.buildDirectory.dir('packaged-server-run') +def packagedClientRunDirectory = layout.buildDirectory.dir('packaged-client-run') + +def requireRuntimeDirectory = { Provider configuredPath, String propertyName -> + if (!configuredPath.isPresent()) { + throw new GradleException("Pass -P${propertyName}=") + } + File runtime = file(configuredPath.get()) + if (!runtime.isDirectory()) throw new GradleException("${propertyName} does not name a directory: ${runtime}") + runtime +} + +def verifyPackagedLog = { File runDirectory, String passMarker, String label -> + File log = new File(runDirectory, 'logs/latest.log') + if (!log.isFile()) throw new GradleException("${label} did not create logs/latest.log") + String text = log.getText('UTF-8') + if (!text.contains(passMarker)) throw new GradleException("${label} completion marker is absent from latest.log") + ['Failed to load correctly', 'Exception stopping the server', 'This crash report has been saved to'].each { failure -> + if (text.contains(failure)) throw new GradleException("${label} log contains fatal marker: ${failure}") + } +} + +def preparePackagedServerSmoke = tasks.register('preparePackagedServerSmoke') { + group = 'verification' + description = 'Stages the public Base Metals and OreSpawn jars with an isolated dedicated-server probe.' + dependsOn releaseJar + dependsOn packagedServerSmokeProbe + dependsOn tasks.named('verifyReleaseDependencies') + doLast { + File runDirectory = packagedServerRunDirectory.get().asFile + delete runDirectory + runDirectory.mkdirs() + copy { + from releaseJar + from packagedServerSmokeProbe + from configurations.orespawnRelease + into new File(runDirectory, 'mods') + } + new File(runDirectory, 'server.properties').setText('''\ +level-name=packaged-server-smoke-world +level-seed=basemetals-release-smoke +online-mode=false +server-port=0 +allow-nether=true +generate-structures=false +spawn-protection=0 +max-tick-time=-1 +''', 'UTF-8') + new File(runDirectory, 'eula.txt').setText('eula=true\n', 'UTF-8') + } +} + +def packagedServerStartupSmoke = tasks.register('packagedServerStartupSmoke', Exec) { + group = 'verification' + description = 'Starts an official Forge dedicated server with the exact packaged release jars.' + dependsOn preparePackagedServerSmoke + doFirst { + File runtime = requireRuntimeDirectory(packagedForgeServerRuntime, 'packagedForgeServerRuntime') + File libraries = new File(runtime, 'libraries') + File forgeDirectory = new File(libraries, 'net/minecraftforge/forge/1.18.2-40.3.0') + File launcher = new File(forgeDirectory, 'forge-1.18.2-40.3.0-server.jar') + File sourceArguments = new File(forgeDirectory, + System.getProperty('os.name').toLowerCase(Locale.ROOT).contains('windows') + ? 'win_args.txt' : 'unix_args.txt') + [launcher, sourceArguments, libraries].each { + if (!it.exists()) throw new GradleException("Incomplete official server runtime: ${it}") + } + File runDirectory = packagedServerRunDirectory.get().asFile + File absoluteArguments = new File(runDirectory, 'forge-server-args.txt') + String libraryPath = libraries.canonicalPath.replace('\\', '/') + '/' + absoluteArguments.setText(sourceArguments.getText('UTF-8') + .replace('libraries/', libraryPath) + .replace('-DlibraryDirectory=libraries', + "-DlibraryDirectory=${libraries.canonicalPath}"), 'UTF-8') + workingDir runDirectory + commandLine java17Launcher.get().executablePath.asFile.absolutePath, + '-Xms512m', '-Xmx2g', "@${absoluteArguments.absolutePath}", 'nogui' + } + doLast { + File runDirectory = packagedServerRunDirectory.get().asFile + File marker = new File(runDirectory, 'packaged-server-smoke-pass.properties') + if (!marker.isFile()) throw new GradleException('Packaged dedicated-server completion marker is missing') + Properties values = new Properties() + marker.withInputStream { values.load(it) } + if (values.getProperty('basemetals') != '3.0.1.118021' + || values.getProperty('orespawn') != '4.0.16.118021' + || values.getProperty('forge') != '40.3.0') { + throw new GradleException("Packaged dedicated-server versions are wrong: ${values}") + } + verifyPackagedLog(runDirectory, 'BASEMETALS_PACKAGED_SERVER_SMOKE PASS', + 'Packaged dedicated-server smoke') + } +} + +def preparePackagedClientSmoke = tasks.register('preparePackagedClientSmoke') { + group = 'verification' + description = 'Stages the public Base Metals and OreSpawn jars with the isolated integrated-client probe.' + dependsOn releaseJar + dependsOn packagedClientSmokeProbe + dependsOn tasks.named('verifyReleaseDependencies') + doLast { + File runDirectory = packagedClientRunDirectory.get().asFile + delete runDirectory + runDirectory.mkdirs() + copy { + from releaseJar + from packagedClientSmokeProbe + from configurations.orespawnRelease + into new File(runDirectory, 'mods') + } + File probeJar = new File(runDirectory, 'mods/basemetals-client-smoke.jar') + new java.util.zip.ZipFile(probeJar).withCloseable { zip -> + ['META-INF/mods.toml', 'pack.mcmeta'].each { requiredEntry -> + if (zip.getEntry(requiredEntry) == null) { + throw new GradleException("Packaged client smoke probe is missing ${requiredEntry}") + } + } + } + new File(runDirectory, 'options.txt').setText('fullscreen:false\nlang:en_us\n', 'UTF-8') + } +} + +def packagedClientStartupSmoke = tasks.register('packagedClientStartupSmoke', Exec) { + group = 'verification' + description = 'Starts an official Forge client with the exact packaged release jars and integrated-world probe.' + dependsOn preparePackagedClientSmoke + doFirst { + File runtime = requireRuntimeDirectory(packagedForgeClientRuntime, 'packagedForgeClientRuntime') + String forgeVersionId = ['1.18.2-forge-40.3.0', 'forge-40.3.0'].find { candidate -> + new File(runtime, "versions/${candidate}/${candidate}.json").isFile() + } + if (forgeVersionId == null) throw new GradleException('Official client runtime has no Forge 40.3.0 profile') + File forgeJsonFile = new File(runtime, "versions/${forgeVersionId}/${forgeVersionId}.json") + File baseJsonFile = new File(runtime, 'versions/1.18.2/1.18.2.json') + File baseJar = new File(runtime, 'versions/1.18.2/1.18.2.jar') + File nativesDirectory = new File(runtime, "natives/${forgeVersionId}") + [forgeJsonFile, baseJsonFile, baseJar, new File(runtime, 'libraries'), + new File(runtime, 'assets'), nativesDirectory].each { + if (!it.exists()) throw new GradleException("Incomplete official client runtime: ${it}") + } + def slurper = new JsonSlurper() + Map forgeJson = (Map) slurper.parse(forgeJsonFile) + Map baseJson = (Map) slurper.parse(baseJsonFile) + String currentOsName = System.getProperty('os.name').toLowerCase(Locale.ROOT).with { name -> + name.contains('windows') ? 'windows' : (name.contains('mac') ? 'osx' : 'linux') + } + String currentOsArch = System.getProperty('os.arch') + Map classpathByModule = new LinkedHashMap<>() + [baseJson, forgeJson].each { Map metadata -> + ((List) metadata.libraries).each { Map library -> + List rules = (List) library.rules + boolean allowed = rules == null || rules.isEmpty() + if (rules != null) { + rules.each { Map rule -> + Map os = (Map) rule.os + boolean matches = os == null || (os.name == currentOsName + && (os.arch == null || os.arch == currentOsArch)) + if (matches) allowed = rule.action == 'allow' + } + } + if (!allowed) return + String relative = (String) ((Map) ((Map) library.downloads).artifact).path + File artifact = new File(runtime, "libraries/${relative}") + if (!artifact.isFile()) throw new GradleException("Missing official client library: ${artifact}") + List coordinates = ((String) library.name).split(':') as List + String module = coordinates.size() >= 2 + ? "${coordinates[0]}:${coordinates[1]}" : (String) library.name + classpathByModule.put(module, artifact) + } + } + List classpathFiles = new ArrayList<>(classpathByModule.values()) + classpathFiles.add(baseJar) + String libraryDirectory = new File(runtime, 'libraries').absolutePath + List forgeJvmArguments = ((List) ((Map) forgeJson.arguments).jvm) + .collect { String argument -> + argument.replace('${library_directory}', libraryDirectory) + .replace('${classpath_separator}', File.pathSeparator) + .replace('${version_name}', (String) forgeJson.inheritsFrom) + } + List gameArguments = [] + gameArguments.addAll((List) ((Map) forgeJson.arguments).game) + gameArguments.addAll([ + '--username', 'BaseMetalsValidation', + '--version', (String) forgeJson.id, + '--gameDir', packagedClientRunDirectory.get().asFile.absolutePath, + '--assetsDir', new File(runtime, 'assets').absolutePath, + '--assetIndex', (String) ((Map) baseJson.assetIndex).id, + '--uuid', '00000000-0000-0000-0000-000000000001', + '--accessToken', 'validation-token', + '--userType', 'legacy', + '--versionType', 'release', + '--width', '854', '--height', '480' + ]) + File runDirectory = packagedClientRunDirectory.get().asFile + workingDir runDirectory + File clientJava = packagedClientJavaExecutable.isPresent() + ? file(packagedClientJavaExecutable.get()) : java17Launcher.get().executablePath.asFile + if (!clientJava.isFile()) { + throw new GradleException("packagedClientJavaExecutable does not name a Java executable: ${clientJava}") + } + List command = [ + clientJava.absolutePath, + '-Xms512m', '-Xmx2g', + '-Dbasemetals.integratedWorldSmoke=true', + '-Dbasemetals.integratedWorldSmokeName=basemetals-packaged-client-smoke', + "-Djava.library.path=${nativesDirectory.absolutePath}", + '-Dminecraft.launcher.brand=basemetals-validation', + '-Dminecraft.launcher.version=1' + ] + command.addAll(forgeJvmArguments) + command.addAll(['-cp', classpathFiles.collect { it.absolutePath }.join(File.pathSeparator), + (String) forgeJson.mainClass]) + command.addAll(gameArguments) + commandLine(command) + } + doLast { + verifyPackagedLog(packagedClientRunDirectory.get().asFile, + 'BASEMETALS_INTEGRATED_SMOKE PASS', 'Packaged client smoke') + } +} + +tasks.register('packagedRuntimeSmoke') { + group = 'verification' + description = 'Runs both official packaged client and dedicated-server startup gates.' + dependsOn packagedServerStartupSmoke + dependsOn packagedClientStartupSmoke +} diff --git a/gradle/verification/support.gradle b/gradle/verification/support.gradle new file mode 100644 index 00000000..80ab4cfe --- /dev/null +++ b/gradle/verification/support.gradle @@ -0,0 +1,133 @@ +// Shared validation gates that do not require an installed packaged runtime. +tasks.named('test', Test).configure { + useJUnitPlatform() +} + +def prepareCustomDimensionSmoke = tasks.register('prepareCustomDimensionSmoke', Sync) { + group = 'verification' + description = 'Installs the runtime-only custom-dimension datapack into the disposable smoke world.' + from layout.projectDirectory.dir('src/smoke/datapack') + into layout.projectDirectory.dir('run/smoke-custom-dimension/datapacks/basemetals-smoke') +} + +tasks.matching { it.name == 'runServer' }.configureEach { + if (providers.gradleProperty('smokeCustomDimension').isPresent()) { + dependsOn prepareCustomDimensionSmoke + } +} + +def upgradeWorldSmokeRequested = providers.gradleProperty('upgradeWorldSmoke').isPresent() +def legacyWorldSmokeRequested = providers.gradleProperty('legacyWorldSmoke').isPresent() +def configuredUpgradeWorldName = upgradeWorldSmokeRequested + ? providers.gradleProperty('upgradeWorldName').orNull + : providers.gradleProperty('legacyWorldName').orNull +def upgradeSmokeResultFile = configuredUpgradeWorldName == null + ? null + : layout.projectDirectory.file("run-upgrade/${configuredUpgradeWorldName}/BASEMETALS_UPGRADE_SMOKE_RESULT.txt") + +def verifyUpgradeWorldSmokeResult = tasks.register('verifyUpgradeWorldSmokeResult') { + group = 'verification' + description = 'Fails unless the selected upgraded-world smoke writes its expected success marker.' + onlyIf { upgradeWorldSmokeRequested || legacyWorldSmokeRequested } + doLast { + if (configuredUpgradeWorldName == null || configuredUpgradeWorldName.isBlank()) { + throw new GradleException('An upgraded-world smoke requires -PupgradeWorldName or -PlegacyWorldName') + } + if (configuredUpgradeWorldName.contains('/') || configuredUpgradeWorldName.contains('\\') + || configuredUpgradeWorldName == '.' || configuredUpgradeWorldName == '..') { + throw new GradleException("Invalid upgraded-world smoke name: ${configuredUpgradeWorldName}") + } + File result = upgradeSmokeResultFile.asFile + if (!result.isFile()) { + throw new GradleException("Upgraded-world smoke did not write ${result}") + } + String expected = upgradeWorldSmokeRequested + ? 'BASEMETALS_UPGRADE_SMOKE PASS' + : 'BASEMETALS_LEGACY_WORLD_SMOKE PASS' + String actual = result.getText('UTF-8').trim() + if (!actual.startsWith(expected)) { + throw new GradleException("Upgraded-world smoke failed: ${actual}") + } + } +} + +tasks.matching { it.name == 'runServer' }.configureEach { + if (upgradeWorldSmokeRequested || legacyWorldSmokeRequested) { + finalizedBy verifyUpgradeWorldSmokeResult + doFirst { + if (configuredUpgradeWorldName == null || configuredUpgradeWorldName.isBlank()) { + throw new GradleException('An upgraded-world smoke requires -PupgradeWorldName or -PlegacyWorldName') + } + if (configuredUpgradeWorldName.contains('/') || configuredUpgradeWorldName.contains('\\') + || configuredUpgradeWorldName == '.' || configuredUpgradeWorldName == '..') { + throw new GradleException("Invalid upgraded-world smoke name: ${configuredUpgradeWorldName}") + } + delete upgradeSmokeResultFile + } + } +} + +def generatedResourcesDir = layout.projectDirectory.dir('src/generated/resources') +def generatedSnapshotFile = layout.buildDirectory.file('tmp/generated-data-before.json') +def generatedFingerprint = { + fileTree(generatedResourcesDir).matching { exclude '.cache/**' }.files + .findAll { it.isFile() } + .sort { a, b -> a.absolutePath <=> b.absolutePath } + .collectEntries { File resource -> + String relative = generatedResourcesDir.asFile.toPath().relativize(resource.toPath()) + .toString().replace('\\', '/') + byte[] digest = java.security.MessageDigest.getInstance('SHA-256').digest(resource.bytes) + [(relative): digest.encodeHex().toString()] + } +} + +def snapshotGeneratedData = tasks.register('snapshotGeneratedData') { + group = 'verification' + description = 'Snapshots generated resources before data generation.' + doLast { + File snapshot = generatedSnapshotFile.get().asFile + snapshot.parentFile.mkdirs() + snapshot.setText(groovy.json.JsonOutput.toJson(generatedFingerprint()), 'UTF-8') + } +} + +tasks.matching { it.name == 'runData' }.configureEach { + mustRunAfter snapshotGeneratedData +} + +def verifyGeneratedData = tasks.register('verifyGeneratedData') { + group = 'verification' + description = 'Fails when runData changes checked-in generated resources.' + dependsOn snapshotGeneratedData, 'runData' + doLast { + Map before = new groovy.json.JsonSlurper().parse(generatedSnapshotFile.get().asFile) as Map + Map after = generatedFingerprint() + if (before != after) { + Set changed = new TreeSet<>(before.keySet() + after.keySet()) + changed.removeIf { before[it] == after[it] } + throw new GradleException("Generated-resource drift detected after runData: ${changed.join(', ')}") + } + } +} + +tasks.named('check').configure { + dependsOn verifyGeneratedData + dependsOn tasks.named('verifyReleaseDependencies') + dependsOn tasks.named('verifyMavenCoordinates') + dependsOn tasks.named('verifyReleaseConfiguration') +} + +def prepareGameTestStructures = tasks.register('prepareGameTestStructures', Copy) { + group = 'verification' + description = 'Copies the tracked SNBT fixtures into the GameTest working directory.' + from layout.projectDirectory.dir('src/gametest/resources/gameteststructures') + into layout.projectDirectory.dir('run/gameteststructures') +} + +tasks.matching { it.name == 'prepareRunGameTestServer' }.configureEach { + dependsOn prepareGameTestStructures +} + +tasks.matching { it.name == 'runGameTestServer' }.configureEach { + dependsOn prepareGameTestStructures +}