I wanted to derive all of existence from pure thought stuff. Instead I had to fix a Gradle bug.
So I rewrote their publishing stack. 6.3× less code. 2× faster on a ten-module build. 99.6% coverage. The four-year-old bug they declined to fix? Fixed by default.
I was going to start telling you about apt and contrax.
But first I have to tell you about a bug. I do not want to be telling
you about a bug.
I want to be deriving all of existence from pure thought stuff, the way a real GODDAMN FUCKING COMPUTER SCIENTIST does. But there is a phenomenon that happens whenever one looks at reality and tries to make it just a little bit better.
When you're a kid, you laugh. When you reach the age of being past the years that you should have saved the world from Sephiroth and piloted a mech to save the world from Angels — only to discover that Humans are the Real Monsters — all you want to do is give that man a hug. It really do be like that. It Do.
The only one who feels this more keenly than the person trying to make minor home improvements is The Programmer. "I want to write some code today. Oh, I need to do a system update. Oh, some critical system file has gone missing. Oh, for some reason I can't mount my hard drive. OH MY FUCKING ROUTER NEEDS TO BE RESET, KILL ME!"
Let us turn to the scriptures of The Holy Master Mickens, KNIGHT 1:1:
A person who can debug a device driver or a distributed system is a person who can be trusted in a Hobbesian nightmare of breathtaking scope; a systems programmer has seen the terrors of the world and understood the intrinsic horror of existence. The systems programmer has written drivers for buggy devices whose firmware was implemented by a drunken child or a sober goldfish. The systems programmer has traced a network problem across eight machines, three time zones, and a brief diversion into Amish country, where the problem was transmitted in the front left hoof of a mule named Deliverance. The systems programmer has read the kernel source, to better understand the deep ways of the universe, and the systems programmer has seen the comment in the scheduler that says "DOES THIS WORK LOL," and the systems programmer has wept instead of LOLed, and the systems programmer has submitted a kernel patch to restore balance to The Force and fix the priority inversion that was causing MySQL to hang. A systems programmer will know what to do when society breaks down, because the systems programmer already lives in a world without law.
This isn't even a smart bug. This is a stupid fucking bug. This is the kind of thing that tries a man's soul.
What is the bug
When you use a build system or a dependency-management system, you basically say: "listen, I could write main methods to do everything. But ain't no one got time for that. Just give me some wiring and save my eyes from having to bleed every time I have to configure something, and we're good." But let's say, for some reason, you're a prodigious writer who just can't shut the fuck up in code. You make tons of libraries. You make a graph data structures API. You make a serialization API. Then you try to serialize a graph, and you say…
Why won't both of these api JARs go on the same classpath?
Whenever you share something in a common space, you need a unique way to
describe that thing. An Identifier is the technical term. Maven uses
GAV as its coordinates, which is a cute and technically
correct way of thinking about your ecosystem: a coordinate plane in space.
GAV is group, artifact id, and version, so we make
things like software.visionary.graphs:api:2026.05.10 and we know
that's universally distinct from
software.visionary.serialization:api:2026.05.10 because we
follow the dots. Namespaces — as the Python community told us in
PEP 20 — are
"a honking great idea!"
Gradle and Maven honor namespaces for publication. The artifacts get
disambiguated at the directory layer:
~/.m2/repository/software/visionary/graphs/api/2026.05.10/api-2026.05.10.jar
and
~/.m2/repository/software/visionary/serialization/api/2026.05.10/api-2026.05.10.jar.
Different paths. Different groups. Same artifactId, same filename, but
everyone agrees they're distinct because that's what namespaces are
for.
Then Gradle pulls the artifacts to actually build your goddamn software,
and it strips out the information that would uniquely
identify them. Both files get copied into one flat directory
(build/install/<app>/lib/) and Gradle observes —
startled and betrayed — that
artifact ids have to be globally unique:
* What went wrong:
Execution failed for task ':installDist'.
> Entry lib/api-1.0.0.jar is a duplicate but no duplicate handling
strategy has been set.
BUILD FAILED
WHAT THE FUCK?!?
It's not a config issue. It is structural. Here are the lines.
Filed
May 2022 as
gradle/gradle#20294. Closed by the Gradle team as "not planned" April 2025.
The convention is hardcoded at two layers of
gradle/gradle v9.5.0's dependency-management subsystem.
Links below pin the v9.5.0 tag.
Gate — mavenLocal()'s module validator at
DefaultMavenLocalArtifactRepository.java#L121:
artifact = metaData.artifact("jar", "jar", null);
That synthesizes a lookup for a file with type "jar", extension "jar", and
classifier null. If
artifactResolver.artifactExists(artifact, ...) returns false on
the next line — i.e. if the conventional-classifier file isn't physically
present at the POM-derived path — the entire module is rejected with
"POM file found for module … but no artifact found. Ignoring."
Publishing only a group-classifier sidecar — say,
api-2026.05.10-software.visionary.graphs.jar — fails
this gate: the validator's null-classifier lookup
doesn't find api-2026.05.10.jar at the conventional
path, so the module is rejected wholesale even though the bytes
it wants are sitting right next to it on disk.
Resolver — the artifact-name synthesis used when Gradle
constructs the primary artifact for a module without Gradle Module Metadata, at
DefaultArtifactMetadataSource.java#L74:
return new DefaultIvyArtifactName(moduleComponentIdentifier.getModule(), "jar", "jar");
Three positional arguments: name (= artifactId), type "jar", extension "jar".
The classifier parameter is the implicitly-null fourth slot of
the four-arg
DefaultIvyArtifactName constructor.
The resolver knows how to look up exactly one filename
per module coordinate — <artifactId>-<version>.jar,
never a classifier-suffixed variant. Even if a module's POM
happened to point at a differently-named primary jar, this code
path can't synthesize a reference to it.
Two hardcoded literals at two layers, both ratifying the same convention:
"the canonical jar lives at
<groupPath>/<artifactId>/<version>/<artifactId>-<version>.jar,
with no classifier and no groupId-disambiguation in the filename."
Maven coordinates resolve groupId at the repository layer; Gradle's
archiveBaseName default and these two resolution paths don't.
This bug isn't a config error. It's structural.
Reproduction
Save as repro.py, run with python3 repro.py on
any Linux or macOS host. The script bootstraps
SDKMAN into ~/.sdkman on
first run, then installs the JDK + Gradle it needs, builds two trivial
library projects with the same artifactId + version but
different groups, and tries to consume both from a third project. The
whole thing lives in /tmp/gradle-20294-repro/ with a
dedicated GRADLE_USER_HOME so no corporate init scripts
interfere. Re-runnable; on the second run, SDKMAN's cache makes it fast.
#!/usr/bin/env python3
"""
gradle/gradle#20294 reproducer.
Two publishers share an artifactId; one consumer depends on both; installDist
trips on the duplicate filename. Self-bootstrapping: pulls SDKMAN + Java +
Gradle into ~/.sdkman if missing, drives the repro from a hermetic /tmp tree.
"""
from __future__ import annotations
import os, shutil, subprocess, sys
from pathlib import Path
from textwrap import dedent
WORK = Path("/tmp/gradle-20294-repro")
GRADLE = "9.5.0"
JAVA = "21.0.5-tem"
SDKMAN = Path.home() / ".sdkman"
def banner(s: str) -> None:
print(f"\n\033[1;36m── {s} ──\033[0m")
def bash(cmd: str) -> None:
subprocess.run(["bash", "-c", cmd], check=True)
def sdk(args: str) -> None:
bash(f"source '{SDKMAN}/bin/sdkman-init.sh' && yes | sdk {args}")
def bootstrap() -> Path:
if not SDKMAN.exists():
banner("installing SDKMAN")
bash("curl -fsSL 'https://get.sdkman.io?rcupdate=false' | bash")
if not (SDKMAN / "candidates/java/current").exists():
banner(f"installing Java {JAVA}")
sdk(f"install java {JAVA}")
gradle = SDKMAN / f"candidates/gradle/{GRADLE}/bin/gradle"
if not gradle.exists():
banner(f"installing Gradle {GRADLE}")
sdk(f"install gradle {GRADLE}")
return gradle
def write(path: Path, body: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(dedent(body))
def publisher(name: str) -> Path:
root = WORK / name
write(root / "settings.gradle.kts", 'rootProject.name = "api"\n')
write(root / "build.gradle.kts", f"""
plugins {{ java; `maven-publish` }}
group = "com.example.{name}"
version = "1.0.0"
publishing {{
publications {{ create<MavenPublication>("lib") {{ from(components["java"]) }} }}
repositories {{ maven {{ url = uri("{WORK}/m2") }} }}
}}
""")
write(root / "src/main/java/example/Foo.java",
"package example; public class Foo {}\n")
return root
def consumer() -> Path:
root = WORK / "app"
write(root / "settings.gradle.kts", f"""
dependencyResolutionManagement {{
repositories {{ maven {{ url = uri("{WORK}/m2") }} }}
}}
rootProject.name = "consumer"
""")
write(root / "build.gradle.kts", """
plugins {{ application }}
dependencies {{
implementation("com.example.foo:api:1.0.0")
implementation("com.example.bar:api:1.0.0")
}}
application {{ mainClass.set("example.Main") }}
""".replace("{{", "{").replace("}}", "}"))
write(root / "src/main/java/example/Main.java",
"package example;\npublic class Main { public static void main(String[] a) {} }\n")
return root
def gradle(at: Path, bin_: Path, *args: str) -> subprocess.CompletedProcess:
env = {
**os.environ,
"JAVA_HOME": str(SDKMAN / "candidates/java/current"),
"GRADLE_USER_HOME": str(WORK / ".gradle"),
}
return subprocess.run(
[str(bin_), "--no-daemon", *args],
cwd=at, env=env, text=True, capture_output=True,
)
def main() -> int:
if WORK.exists():
shutil.rmtree(WORK)
(WORK / "m2").mkdir(parents=True)
g = bootstrap()
banner("publishing foo:api and bar:api (same artifactId, different groups)")
for name in ("foo", "bar"):
gradle(publisher(name), g, "-q", "publishToMavenLocal",
f"-Dmaven.repo.local={WORK}/m2").check_returncode()
banner("running consumer's installDist (expect collision)")
r = gradle(consumer(), g, "installDist")
print(r.stdout, r.stderr, sep="")
if "is a duplicate but no duplicate handling" in r.stdout + r.stderr:
print("\n\033[1;32m✓ reproduced gradle/gradle#20294\033[0m")
return 0
print("\n\033[1;31m✗ did NOT reproduce — Gradle behaviour may have changed\033[0m")
return 1
if __name__ == "__main__":
sys.exit(main())
On Gradle 9.5 the script prints the BUILD FAILED message
shown at the top of this page and exits 0 ("bug reproduced"). Modern
Gradle catches the collision and refuses to lay out the distribution.
To make it succeed you'd have to set
duplicatesStrategy = DuplicatesStrategy.INCLUDE on the
distZip/installDist tasks — at which point one
jar silently overwrites the other and you ship a distribution missing
classes from whichever dependency lost the coin flip.
What really grinds my gears
I will admit this alone made me salty enough to do this:
the Gradle
documentation has the Caucasity to claim that the
maven-publish plugin publishes
"the project's production JAR file — the one produced by the jar
task". All I asked them to do was change that one fucking
line. It is not true. You can customize the
jar task's archiveClassifier or
archiveFileName. The publishing scheme — for "reasons" that
relate to "conventions" — will simply ignore your customizations and
tell you to fuck off. The documentation has been wrong since at least
gradle#20294 was filed in 2022.
This is where I'm unreasonable. "Better" engineers
than me — friends from
college, some from industry — would look at this situation and accept the
hack their AI proposed: "Oh it's fine. Just make it
software.visionary.graphs:graphs-api:2026.05.10, move on with
your life, and have a nice day." But then I look inside my
~/.m2/repository and I see that my beautiful folder structure
that had my modules organized by their components and responsibilities has
turned into a goddamn public swimming pool, my kids are everywhere, and
they're all covered in piss.
If the ugliness of a little repetition bothered me, I could come up with
something fun! Coochie-coochie-coo code names! I could have
software.visionary:figa:2026.05.10,
software.visionary:cuca:2026.05.10,
software.visionary:conche:2026.05.10. It's always possible to
make unique strings for your artifacts. But why do I have to do
this and maintain an internal mental mapping?
You walk into the house to get your keys. You see someone threw rotten eggs on the ceiling. The floor is covered in vomit, glitter, and spoiled baba ganoush. There are spiders and centipedes in the corner mating, trying to produce some hybrid monstrosity that will haunt your dreams for the rest of your life. There's someone in the corner taking a shit. You should take your keys, on the table by the door, and run. Run far away. Far, far away and never return. But then you see them reaching for an old copy of The Hobbit, with the beautiful drawn mountains, because they're about to wipe their ass with it. And you say "NO. THAT'S ENOUGH." You take the shirt off your back, snatch the book from their clawing hands, and say: "Here, you monster. Use this. But NEVER THIS."
(The DVDs for the Jackson movies, on the other hand, you not only use, you BURN them and swear blood vendetta for what Jackson did to your childhood.)
So yeah, I had to do something about it. No, it wasn't a pull request.
When you ask a programmer who owns an external system to make even a slight documentation change — to tell the truth about what their system does — and the answer is a shrug and a "not my fault", you don't turn around and say "I want to collaborate with this ecosystem to make it better! 😀" You use it as little as possible, with RAW RAGE every time. When the rage builds to an intolerable level, you rewrite their system in the way that someone who was actually good at what they do would do. As the ancestors did. As our cyborg children will do in The Future that is already Now.
So I wrote software.visionary.publish
Yeah, it fixes the bug. Yeah, it has less code, less complexity, more coverage, and is more performant. Momma didn't raise no punk-ass bitch. I basically wrote it because I need it to publish all the things I actually want to talk about.
Leanness
SCC analysis, COCOMO project type "organic", developer salary $150K USD.
Counted: src/main/ only. Tests, build files, and generated
code excluded. For the Gradle stack, "scope" = the four publishing
subprojects in gradle/gradle v9.5.0:
platforms/software/{publish,maven,ivy,signing}/src/main.
| Project | Files | LOC | Complexity | Cmplx/LOC | Estimated cost |
|---|---|---|---|---|---|
| software.visionary.publish | 44 | 1,939 | 78 | 4.0% | $54,144 |
| vanniktech-maven-publish-plugin (wrapper) | 17 | 1,362 | 163 | 12.0% | $37,366 |
| gradle maven-publish stack | 222 | 12,230 | 947 | 7.7% | $374,577 |
| effective surface of vanniktech (wrapper LOC + maven-publish stack underneath) |
239 | 13,592 | 1,110 | 8.2% | $411,943 |
6.3× smaller than Gradle's stack, 7× smaller than vanniktech's effective
surface, at half the complexity-per-line of either. Vanniktech looks
lean in isolation (1,362 LOC) only until you remember it's a wrapper —
every consumer pulls the whole 12,230-line maven-publish
stack underneath. The publish plugin replaces both, end to end.
Performance — wall-clock
Methodology:
- Identical synthetic Gradle projects — one-module
singleand ten-subprojectmulti. - Each plugin applied via a minimal stanza producing the same artifact set: primary jar + sources jar + javadoc jar + POM + Gradle Module Metadata.
publishToMavenLocalwall-clock, N=5 per cell, freshGRADLE_USER_HOMEper cold iteration.- Parallelism capped at 1 so concurrent runs don't distort timings.
- Full driver, fixtures, and stanzas in
benchmark/;
raw per-iteration JSON at
/tmp/publish-bench-logs/.
| Plugin | cold publishToMavenLocal(1 module) |
cold publishToMavenLocal(10 modules) |
warm help(CC hit) |
|---|---|---|---|
| software.visionary.publish | 23.44s (p95 23.63) | 35.12s (p95 38.37) | 0.34s |
| maven-publish | 24.82s | 43.47s | 0.34s |
| vanniktech-maven-publish | 60.20s (p95 60.95) | 76.45s (p95 80.40) | 0.38s |
~20% faster than maven-publish on a ten-subproject
cold publish. Roughly 2× faster than vanniktech. Per-subproject
plugin apply + POM generation + GMM emission scales linearly; the leaner
the plugin, the cheaper each one. The gap widens monotonically with project
size — which matters disproportionately to Android monoliths. A typical
published Android app's Gradle build tree runs in the dozens to hundreds
of subprojects. What looks like an 8-second delta on ten modules
translates to entire minutes on a real Android megaproject every
time CI does a publish.
The vanniktech iterations were also bimodal (alternating ~30s and ~60s) while the other two were tight to within a second across all five runs — so vanniktech is less predictable too, not just slower. Identifying the precise cause was out of scope for this bench; the wall-clock numbers are what they are. Warm-daemon configuration-cache-hits are indistinguishable across all three (~0.34s); once the daemon is warm and the configuration cache is hot, plugin overhead is below measurement noise.
Quality
SonarQube CE 26.5 static-analysis scan, JaCoCo coverage on the two
projects where the full build was feasible. Gradle's stack is scanned
source-only — a from-source build of gradle/gradle is
impractical in a one-shot session.
| Project | Bugs | Code Smells | Line coverage | Reliability | Maintainability |
|---|---|---|---|---|---|
| software.visionary.publish | 0 | 0 | 99.3% line · 91% branch | A | A |
| vanniktech-maven-publish-plugin | 0 | 18 | 18.8% | A | A |
| gradle maven-publish stack | 0 | 143 | n/a (source-only scan) | A | A |
The seven missed lines in software.visionary.publish
(out of 1,102) and the 35 uncovered branches (of 384) are honest: a handful of Kotlin-synthesized
$DefaultImpls stubs that can't be reached from Kotlin
call sites, plus deliberately-unhit timeout branches (a 30-second
gcloud wait, a 60-second GPG wait) — exercising them would gate the
test suite on real-time delays. No gaming of coverage; nothing
skipped.
The same build, written three ways
What the numbers above look like at the call site. Below is the minimum
build.gradle.kts needed to publish a single Java module
(primary jar + sources jar + javadoc jar + POM + module metadata) under
each plugin. The full files live in the
benchmark/stanzas/
directory and are what the wall-clock benchmark timed verbatim.
software.visionary.publish — 4 lines
plugins {
java
id("software.visionary.publish") version "2026.05.10"
}
Apply, done. Every POM field — name, description, URL, licenses, SCM,
developers — is read from gradle.properties by convention.
No DSL block, no publications {}, no manual classifier
wiring. Sources and javadoc jars are registered automatically. The
property key names are deliberately identical to Vanniktech's so you
swap with a one-line plugin id change.
com.vanniktech.maven.publish — 4 lines, same shape
plugins {
java
id("com.vanniktech.maven.publish") version "0.36.0"
}
Vanniktech is the de-facto community convention software.visionary.publish
adopts wholesale — same POM_* property names in
gradle.properties, same sources/javadoc auto-wiring, same
apply-and-go ergonomics. The difference is at runtime: Vanniktech wraps
maven-publish underneath, so it inherits every structural
bug — including gradle#20294. And it has its own breakage
history: SonatypeHost.DEFAULT stopped working in version 0.32
when OSSRH was retired, breaking every consumer that had pinned the older
constant. The polite wrapper is still standing on the same rotten floor.
Gradle's built-in maven-publish — ~45 lines of nested DSL
plugins {
java
`maven-publish`
}
java {
withSourcesJar()
withJavadocJar()
}
publishing {
publications {
create<MavenPublication>("mavenJava") {
artifactId = providers.gradleProperty("POM_ARTIFACT_ID").get()
from(components["java"])
pom {
name.set(providers.gradleProperty("POM_NAME"))
description.set(providers.gradleProperty("POM_DESCRIPTION"))
url.set(providers.gradleProperty("POM_URL"))
inceptionYear.set(providers.gradleProperty("POM_INCEPTION_YEAR"))
licenses {
license {
name.set(providers.gradleProperty("POM_LICENSE_NAME"))
url.set(providers.gradleProperty("POM_LICENSE_URL"))
distribution.set(providers.gradleProperty("POM_LICENSE_DIST"))
}
}
scm { /* … same shape × 3 … */ }
developers { /* … same shape × 4 … */ }
}
}
}
repositories { mavenLocal() }
}
Every POM field has to be restated by hand, even though every value is
already in gradle.properties under the de-facto
community-standard names. No sources or javadoc jar unless you add
java { withSourcesJar(); withJavadocJar() }. This is what
ships with Gradle — the outlier among the three.
OK fine, but does it actually FIX the goddamn bug?
Yes. By default. No flag, no configuration knob. Apply the plugin and you get two byte-identical jars per publication:
-
<artifactId>-<version>.jar— empty-classifier file at the Maven-conventional path. Satisfies theDefaultMavenLocalArtifactRepository.java#L121validator gate above. Maven-style consumers that don't read GMM still resolve it. -
<artifactId>-<version>-<groupId>.jar— the group-classifier sidecar. Globally unique filename. The generated.module(Gradle Module Metadata) declares this as the variant artifact, so Gradle consumers resolve the unique-named file at compile and runtime. Flat-dir collection (installDist,jlink, shadow JARs) sees distinct filenames and doesn't collide.
"Byte-identical jars" sounds like it doubles disk usage. It doesn't. The
two filenames are equal-class directory entries to the same inode, via
Files.createLink:
Files.write(target1, artifact.bytes()) // <artifactId>-<v>.jar
Files.createLink(target2, target1) // <artifactId>-<v>-<group>.jar
Same inode. Refcount 2. ls -li shows one shared inode
number across both filenames. cmp -s exits 0. rm
one of them and the other still works; rm both and the bytes
finally disappear. Zero duplicated bytes on disk for a problem
that affects every JAR a publisher emits. Constraint: hardlinks
require both names on the same filesystem. Both target paths sit in the
same ~/.m2/.../<artifact>/<version>/ directory,
so this is always satisfied for the local Maven repo. Full investigation,
step-by-step verification repro, and edge cases in
bugs/gradle-20294-workaround.md.
And here's the joke — the global cost of "not planned"
The obvious naive shape of the workaround is just: emit
two byte-identical JARs under two different filenames
and ship both. The validator gets the conventional file it requires;
downstream consumers get a uniquely-named sidecar to lay out without
collision. Same bytes go to the publisher's disk, up to the registry,
and back down to every consumer's ~/.m2/ — twice.
Now picture a world where every Maven repo on the internet — Central, a self-hosted JFrog Artifactory, an org's internal Nexus, GitHub Packages, GCP Artifact Registry, the dozens of smaller registries behind corporate VPNs — stores literally what you uploaded, byte for byte. No dedup at the storage tier. Two URLs, two physical copies, two columns on the cents-per-GB-month invoice. And every consumer who depends on a collision-class artifact downloads both copies cold, parks both copies on local disk, and pays the full bandwidth bill on every CI cache miss. Hard on the repo. Hard on the client. Multiply by every publisher that adopts the workaround.
Order-of-magnitude math, if every Maven Central publisher took that shape:
- Central holds ~5M unique GAV jars at time of writing.
- Maybe ~10% of
artifactIdstrings are "common nouns" —api,core,common,util,client,model— repeated across multiple groups, so candidates for the dual-emit workaround. - Median jar size on Central is ~500 KB.
- ~2.5 TB of duplicated bytes uploaded to and stored on the server, that didn't need to exist if the resolution convention had a way to express "same artifact, two filenames."
- Every dev machine that consumes a collision-class artifact pays ~50–200 GB of cache bloat over its useful life. Multiple exabytes rolled up across every dev machine on Earth, if the workaround went viral.
Two cheap fixes make this whole imagined disaster disappear. The first
lives in the publisher's ~/.m2/ and is one line of code:
Files.createLink(sidecar, primary). Two directory entries,
one inode, refcount 2 — the same bytes are now reachable under both
filenames at zero extra disk. That's the local layer
fully closed: the publisher and every consumer pay one copy on disk,
period. software.visionary.publish does this by default.
The second is one field in Gradle Module Metadata. linkTo
tells a GMM-aware resolver "this file is the same bytes as that other
file — fetch the target once, materialise this one as a local
hardlink." Now the bandwidth bill drops to one transfer per pair, too:
"files": [
{
"name": "api-1.0.0.jar",
"url": "api-1.0.0.jar",
"linkTo": "api-1.0.0-com.example.foo.jar",
"size": 12345,
"sha512": "..."
},
{
"name": "api-1.0.0-com.example.foo.jar",
"url": "api-1.0.0-com.example.foo.jar",
"size": 12345,
"sha512": "..."
}
]
Total cost to ship the fix: one new optional field in a JSON schema
Gradle already controls, and a small patch to MavenResolver
to honor it. The duplication problem evaporates globally. The bug is
four years old and still open because nobody upstream felt like adding
the field.
How I built it
It's not one of my finest works. I let Claude do a lot of it. But when it
started making some imperative
TRANSACTION SCRIPT
piece of shit with embedded strings hardcoded into the middle of the apply
pipeline, I did tell it NO. I told it to use a
VISITOR,
which made the design more tractable and beautiful when working on Maven
Local. It made custom Maven and Maven Central pretty much fall out for
free. The publication is the data; the repository is the visitor; each
visitor knows what to do with each publication shape. Same machinery,
three implementations, no embedded strings, no
if (target == "maven-central") { … } else if … } gauntlet.
Admitting a Dark Secret: I actually did spend a day and a half building a much more elaborate prototype using Ken Pugh's Interface Oriented Design (a beautiful book that everyone should read) and IRI cards. I tried to design for telemetry and redundant failover with an elegant asynchronous event-based system, custom serialization strategies, the works. It did cute things — turning POMs into YAML, converting Gradle projects to Maven or Dart — and after looking upon my own works as Mighty and despairing over its complexity for a mundane task, I threw it away. That's always a difficult thing to do. Part of getting to be an Old Fuck Engineer, as George Carlin would say, is starting to build the foundations for a castle just for fun and then saying "what the fuck am I doing? I don't need a castle here" and tossing the blueprint before you pour the cement to put up a reasonable tent for the night.
Bonus, surfaced while I was here
Once software.visionary.publish existed, the obvious next
move was to auto-apply it from an org-wide Gradle init script — drop
the script in ~/.gradle/init.d/ on every developer's
machine and every CI container, and every Visionary project picks it
up for free with zero per-repo configuration. The pattern that springs
to mind first — the one literally every Gradle init script example on
the internet uses — is:
// ~/.gradle/init.d/visionary-publishing.gradle — DO NOT USE
initscript {
repositories { mavenCentral() }
dependencies {
classpath 'software.visionary.publish:gradle-plugin:2026.05.10'
}
}
allprojects {
apply plugin: 'software.visionary.publish'
}
Looks innocuous. Runs fine against a plain Java project. Drop it into
a container and the next time anyone tries to build one of the org's
*-conventions repos — every single one applies
kotlin-dsl because Gradle's convention-plugin docs tell
you to — the build dies with:
* What went wrong:
An exception occurred applying plugin request [id: 'org.gradle.kotlin.kotlin-dsl']
> Failed to apply plugin class
'org.jetbrains.kotlin.gradle.plugin.KotlinPluginWrapper'.
> Make sure the Kotlin version 2.2.0 or newer is applied. Otherwise,
detected Kotlin plugin org.jetbrains.kotlin.jvm but was not able to
access Kotlin plugin classes.
Publish is Kotlin-implemented, so loading it via
initscript { classpath } pulls a Kotlin runtime into
Gradle's init-script classloader. kotlin-dsl then loads
its Kotlin runtime into the project classloader. Gradle's
plugin-application engine checks KotlinPluginWrapper.isInstance(...)
across the classloader boundary, sees two different
KotlinPluginWrapper Class objects loaded by two different
classloaders, and refuses to apply either plugin. Every
kotlin-dsl consumer in the org goes down together.
Not a great property for an init script that ships in every
container.
The fix is structural at the init-script level: keep Kotlin out of the
init-script classloader entirely, and inject publish into each project's
buildscript classpath instead — where kotlin-dsl
already lives, so there's only one Kotlin runtime in scope:
// ~/.gradle/init.d/visionary-publishing.gradle — the safe shape
gradle.beforeProject { project ->
project.buildscript {
repositories { mavenLocal(); gradlePluginPortal() }
dependencies {
classpath 'software.visionary.publish:gradle-plugin:2026.05.10'
}
}
project.afterEvaluate {
project.pluginManager.apply('software.visionary.publish')
}
}
beforeProject fires while each project's buildscript
classpath is still mutable. afterEvaluate defers the apply
until the buildscript classloader has been realized. Publish lands in
the project's own classloader, where kotlin-dsl also lives.
One Kotlin runtime per project, no collision. Pinned by a TestKit spec
in the repo (InitDKotlinDslSpec) so a future init.d that
drifts back to the broken pattern re-fails loudly. Full investigation
in
bugs/init-d-breaks-kotlin-dsl-consumers.md.
Why does this matter
A build plugin is something every JVM library team uses on every release.
The cost of using a 12,000-line dependency vs. a 2,000-line one isn't
the disk space; it's the surface area where bugs hide and the time
engineers spend reading docs that lie. The Gradle team has had four
years to fix a documented filename-collision bug in
maven-publish and they declined to. That four-year-old bug
cost real teams real time — not in catastrophic failures, but in the
slow erosion of dev velocity that you only notice when a freshly-clued
junior engineer asks "why does our installDist drop
classes?"
I built software.visionary.publish because I needed it. I
open-sourced it because every JVM library team needs it. If you want
software that's simple, fast, and correct from someone who builds it
that way every day, the work above is what that looks like. I'm
available at
nico@visionary.software.
Documentation that lies, bugs that survive four years, dependency closures the size of cathedrals — none of that is necessary. I will keep building things that prove it.