Skip to content

feat: split into modules, replace the dependency resolver, add build plugins - #2

Merged
TheMeinerLP merged 13 commits into
mainfrom
feat/multi-module-split
Aug 4, 2026
Merged

feat: split into modules, replace the dependency resolver, add build plugins#2
TheMeinerLP merged 13 commits into
mainfrom
feat/multi-module-split

Conversation

@TheMeinerLP

Copy link
Copy Markdown

Splits the project into five published modules, replaces the runtime dependency resolver, and adds optional build plugins for Gradle and Maven.

Modules

Artifact Purpose
minestom-extensions The extension system. Unchanged code, unchanged coordinates.
minestom-extensions-processor @ExtensionInfo and the annotation processor generating extension.json.
minestom-extensions-gradle-plugin Optional. Declares runtime libraries from the Gradle build.
minestom-extensions-maven-plugin Optional. The same for Maven.
minestom-extensions-bom Pins all of the above to one version.

CloudNet v4 compatibility

The core artifact is reproduced byte for byte. All five sources moved as pure renames (R100), the generated POM and Gradle module metadata diff empty against a build of main, every jar entry and CRC matches, and javap over the five public classes is identical. groupId, artifactId, version and dependency scopes are unchanged.

externalDependencies was broken, not just outdated

DependencyGetter is Kotlin-compiled and references kotlin/jvm/internal/Intrinsics 48 times, but the Kotlin stdlib is nowhere in its dependency tree. Any extension declaring externalDependencies died on the first line of loadDependencies:

java.lang.NoClassDefFoundError: kotlin/jvm/internal/Intrinsics
    at net.minestom.dependencies.maven.MavenRepository.<clinit>(MavenRepository.kt:12)

That is an Error, not an Exception, so the catch in loadDependencies never saw it — instead of a clean MISSING_DEPENDENCIES, the server start was torn down. This affects every release so far, independently of this PR.

Replaced with Maven Artifact Resolver, the resolver Maven itself uses and the same approach Paper takes for its plugin library loader. Side effects: off JitPack, and the transitive footprint drops from 52 artifacts to 28 — the old chain pulled in jsoup, maven-invoker, plexus-compiler-javac and an embedded Maven.

One trap worth knowing: the modern RepositorySystemSupplier builds a resolver whose descriptor reader does not interpret POMs. readArtifactDescriptor returns zero dependencies and transitive resolution silently yields nothing. The deprecated MavenRepositorySystemUtils.newServiceLocator() is therefore deliberate and documented on the class.

Two documentation bugs fixed

Both reproduced against real consumer projects built from the README snippets:

  • The Gradle install did not resolve. annotationProcessor extends no other configuration, so a platform imported into implementation never reaches it.
  • The Maven install silently shipped a broken jar. Since JDK 23 javac does not run annotation processors found only on the compile classpath. The documented provided-scope setup produced a green build with no descriptor in the jar, failing only at server startup.

Notes for review

  • 49 tests, all green. Two are worth a look: DescriptorContractTest pins the descriptor field names to DiscoveredExtension (nothing else links them — Gson ignores unknown members, so a rename would break every extension at runtime with a green build), and both plugins have a regression test for the removed-dependency case below.
  • Both plugins write to a file of their own instead of over the processor's copy. Editing in place makes the task's own output its next input, and then a dependency removed from the build lingers in the descriptor forever, because the compile task stays up to date and never regenerates it.
  • The Maven plugin's plugin.xml is hand-maintainedmaven-plugin-development calls an API Gradle 9 removed and has no newer release. PluginDescriptorTest compares it against the mojo so it cannot drift.
  • The Maven plugin does not derive dependencies from a scope. Everything a Minestom extension compiles against is provided; an early version produced a descriptor telling the server to download the annotation processor.

Known issue, not addressed here

Maven consumers of minestom-extensions fail at mvn package on shrinkwrap-resolver-depchain:jar:3.1.4. DependencyGetter:v1.0.1 declares it without <type>pom</type>, so Maven looks for a jar that does not exist. This disappears with the resolver replacement, but is worth knowing for existing releases.

Turns the single-module build into a multi-module one. The root project
becomes an aggregator that publishes nothing itself, and the extension
system moves unchanged into its own subproject.

The published artifact keeps its coordinates and its content: CloudNet v4
compiles against net.onelitefeather:minestom-extensions, so the packages
net.minestom.server.extensions.* and net.hollowcube.minestom.extensions
are a public contract and none of them are touched here. The sources are
moved with git mv, so the history follows them.

The whole maven-publish setup - POM metadata, licences, SCM, the
OneLiteFeather repository and its credentials - is declared once in the
root build instead of being copied per module. Only name, description and
artifactId differ. The java-platform BOM deliberately does not match the
java-library block, so it never gets a sources or javadoc jar.

All modules inherit the single version from gradle.properties, including
its "# x-release-please-version" marker, and are always released together.
Adds net.onelitefeather:minestom-extensions-processor, an annotation
processor that writes the extension descriptor at compile time instead of
leaving it to be maintained by hand.

There is deliberately no entrypoint() element: the entrypoint is the
annotated class itself, so it cannot drift away from a rename or a package
move. It is written as the binary name rather than the canonical one,
because ExtensionManager resolves it with Class.forName(), which needs
com.example.Outer$Inner for a nested class.

The module has no runtime dependencies at all - only javax.annotation
.processing from the JDK. That is why NAME_REGEX is duplicated here rather
than imported: depending on minestom-extensions would put Minestom on the
processor path of every extension build. For the same reason the JSON is
written by hand instead of with Gson.

Non-ASCII characters are escaped as \uXXXX. The Filer encodes with javac's
-encoding while the runtime reads with the JVM default charset, so a build
that is not UTF-8 would otherwise turn an author name into mojibake, or
drop characters it cannot represent.

Validation that would otherwise only fail at server startup happens at
compile time: the name against NAME_REGEX, a single entrypoint per
compilation, that the class extends Extension (skipped when Extension is
not on the classpath), and that repository URLs are http(s).
Nothing linked the names the processor writes to the fields the runtime
reads. The processor deliberately does not depend on minestom-extensions,
and Gson silently ignores JSON members it does not recognise - so renaming
a field in DiscoveredExtension would break every extension at runtime
while leaving the entire build green.

extension-descriptor-contract.json is the exact output of the processor
for a fully populated @ExtensionInfo. The processor module copies it in
and asserts it still reproduces it byte for byte; the core module
deserializes it into the real DiscoveredExtension and asserts every field
arrives, including the nested externalDependencies structure.

Verified by mutation: renaming the field and every use of it - which
compiles cleanly - turns the core suite red instead of shipping a silently
broken descriptor.
DependencyGetter is compiled from Kotlin and references
kotlin/jvm/internal/Intrinsics 48 times, but the Kotlin stdlib is nowhere
in its dependency tree. Any extension declaring externalDependencies
therefore died on the very first line of loadDependencies:

  java.lang.NoClassDefFoundError: kotlin/jvm/internal/Intrinsics
      at net.minestom.dependencies.maven.MavenRepository.<clinit>

And because that is an Error rather than an Exception, the catch in
loadDependencies never saw it: instead of a clean MISSING_DEPENDENCIES the
server start was torn down. The feature has been broken, not degraded.

Replaced with Maven Artifact Resolver - the resolver Maven itself uses,
and the same approach Paper takes for its plugin library loader. It also
gets the project off JitPack, drops the transitive footprint from 52
artifacts to 28 (the old chain pulled in jsoup, maven-invoker,
plexus-compiler-javac and an embedded Maven), and removes an unmaintained
2020 jar that still advertised jCenter.

The wiring goes through the deprecated MavenRepositorySystemUtils
.newServiceLocator() on purpose. The modern RepositorySystemSupplier
builds a resolver whose descriptor reader does not interpret POMs:
readArtifactDescriptor returns zero dependencies and transitive resolution
silently resolves to nothing. That is documented on the class.

The public API is unchanged - loadDependencies and addDependencyFile are
private, and MavenDependencyResolver is package-private.
Writing @externaldependency("com.google.guava:guava:33.4.0-jre") means the
version lives in the source as well as in the build, and the two drift
apart. The plugin lets the build state it once:

    dependencies {
        extensionLibrary("com.google.guava:guava:33.4.0-jre")
    }

extensionLibrary is declaration-only - on no compile or runtime classpath,
nothing from it is bundled. It exists purely to describe what the
extension resolves for itself at startup, so those jars must not leak into
a consumer's classpath. A coordinate without an explicit group and version
fails the build: the server resolves from coordinates alone, with no
access to the build's version catalog or platforms.

The task writes to a file of its own rather than over the processor's
copy, and the jar takes that one instead. Editing in place would make the
task's own output its next input, and the consequence is worse than a
missed up-to-date check: removing an extensionLibrary would leave the
stale coordinate in the descriptor forever, because compileJava stays up
to date and never regenerates the file the task reads. There is a test for
exactly that, and one asserting the jar carries a single descriptor.

Dropping the processor's copy from the jar uses eachFile rather than
exclude(), because an exclude on the jar spec applies to every source
including the replacement, leaving no descriptor at all.

Anything already declared in @ExtensionInfo is kept, and wins on a clash.
The Maven counterpart to the Gradle plugin. The version may be left out
and is then taken from the project's dependencies, which is the point:
the coordinate is named once in the plugin config and versioned once in
<dependencies>, instead of being repeated in the annotation.

    <externalDependency>org.apache.commons:commons-lang3</externalDependency>

Which dependencies to record is stated explicitly. Deriving them from the
provided scope was tried and rejected: everything a Minestom extension
compiles against is provided - Minestom, minestom-extensions, the
annotation processor itself - and an end-to-end run produced a descriptor
instructing the server to download the annotation processor.

Like the Gradle plugin, the goal must not merge into its own previous
output, or a coordinate removed from the POM would linger forever on any
build without clean. It keeps a pristine copy of what the processor wrote
beside the build output and works from that, refreshing it whenever the
class output differs from what the goal last wrote.

META-INF/maven/plugin.xml is maintained by hand: a Maven build would
generate it with maven-plugin-plugin, and the Gradle equivalent calls
ProjectDependency.getDependencyProject(), which Gradle 9 removed, with no
newer release. PluginDescriptorTest compares the descriptor against the
mojo's fields, types, goal and phase, because a drifting descriptor fails
in the user's build - Maven either reports a parameter as unknown or
silently never injects it.
The reusable workflow's default path filter anchors sources at `src/**`,
which stopped matching anything once they moved into
`minestom-extensions/src/` and the other module directories - so a change
touching only sources would no longer trigger a PR build.

Widened to `**/src/**`. `**/*.java` on its own is not enough: non-Java
files under a module's src/ would still be missed, in particular
minestom-extensions-processor's META-INF/services registration, which is
the thing that makes the annotation processor discoverable at all.
Brings the README up to the multi-module layout: a table of the five
artifacts, BOM-based install snippets for Gradle (Kotlin and Groovy) and
Maven, a section on generating extension.json with @ExtensionInfo, and one
on declaring runtime libraries from the build.

Two of the install instructions were wrong and are fixed here, both
reproduced against real consumer projects built from the snippets:

The Gradle block did not resolve at all. `annotationProcessor` extends no
other configuration, so a platform imported into `implementation` never
reaches it and the versionless processor dependency had nothing to resolve
against - "Could not find ...:minestom-extensions-processor:" with an
empty version. It needs its own platform line.

The Maven instructions were wrong for the Java version this project
requires. Since JDK 23 javac no longer runs annotation processors found
only on the compile classpath, so the documented provided-scope setup
produced a green build and a jar with no descriptor in it, failing only
at server startup with "Missing extension.json in extension <jar>".
<annotationProcessorPaths> is now the documented configuration rather
than an aside.

Also notes that Extension#getLogger() needs slf4j-api on the consumer's
compile classpath, since the core artifact only carries it at runtime, and
warns against pointing extensions at repo1.maven.org directly, which the
Maven Central terms of service do not allow for shipped software.
Documents what the build looks like now and, more importantly, the three
places where a well-meant change quietly breaks something:

- the packages under minestom-extensions/ are a public contract with
  CloudNet and must not be renamed,
- MavenDependencyResolver's deprecated service-locator wiring is
  deliberate - the modern supplier silently resolves no transitive
  dependencies,
- the build plugins write to a separate file on purpose, and the Maven
  plugin's descriptor is hand-maintained and covered by a test.
The Maven coordinates in the README are documentation that silently rots.
Two custom managers keep them current: one for the group:artifact:version
examples, one for the maven-compiler-plugin version in the Maven setup
snippet.

The first deliberately matches the generated-output example as well as the
inputs, so the descriptor shown in the README cannot drift away from the
annotation that is supposed to produce it.

This project's own artifacts are excluded - they are versioned by
release-please, and the README shows them as placeholders. Everything
lands in one grouped docs PR rather than a stream of individual ones.

Validated with renovate-config-validator; both patterns are RE2-safe,
which matters because that is what Renovate uses in production.
@github-actions

This comment has been minimized.

@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown

Test results

 18 files   18 suites   1m 35s ⏱️
 54 tests  54 ✅ 0 💤 0 ❌
162 runs  162 ✅ 0 💤 0 ❌

Results for commit 884bc87.

♻️ This comment has been updated with latest results.

The test compares the processor's output against the checked-in contract
byte for byte, and the processor always writes LF. On Windows, Git's
autocrlf hands out a CRLF working copy, so the comparison failed on line
endings alone:

  ExtensionInfoProcessorTest > the generated descriptor matches the
  contract shared with minestom-extensions FAILED

strip() only trims the ends, not the line endings in between.

Fixed at the source with a .gitattributes that checks the repository out
as LF everywhere, keeping CRLF only where Windows needs it (.bat/.cmd) and
LF where sh needs it (gradlew). The test additionally normalises line
endings, so it also survives a working copy created before this file
existed, or an editor that rewrites the contract on save. What the
contract is about is field names, order, indentation and escaping - not
the checkout's line ending policy.

Verified by converting the contract to CRLF and running the suite: red
before, green after.
Both build plugins now record the project version, so the compiler
argument users had to wire up by hand is no longer needed:

    tasks.withType<JavaCompile> {
        options.compilerArgs.add("-Aminestom.extension.version=${project.version}")
    }

The build wins over @ExtensionInfo(version = ...), which matches what the
existing -Aminestom.extension.version already did. Opt out with
useProjectVersion = false to keep the version in the annotation.

Setting it from the task rather than through a compiler argument is
deliberate: it also applies on an incremental build where compileJava is
up to date and the processor does not run at all.

The Gradle side ignores an unset project version. Gradle defaults it to
the string "unspecified", and writing that into a descriptor would replace
a perfectly good annotation value with a placeholder.

Verified end to end for Maven in both directions: with the compiler
argument removed, useProjectVersion=false keeps the annotation's 1.0.0
and true writes the POM's 2.5.0.
v2.2.0 through v2.4.0 only added workflows - docker-publish,
gradle-docker-context and pr-lint - so nothing this repository calls
changed behaviour. Checked against the workflows CHANGELOG.

release-please stays inline for now: the reusable release-please.yml
forwards none of the action's outputs, and the publish job is gated on
`needs.release-please.outputs.release_created`. Switching over as-is would
silently skip publishing on every release. OneLiteFeatherNET/workflows#21
adds those outputs; this repository can move once that is released.
@TheMeinerLP
TheMeinerLP merged commit 30e9b1d into main Aug 4, 2026
10 of 11 checks passed
@github-actions github-actions Bot mentioned this pull request Aug 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant