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.

Hal from Malcolm in the Middle, mid-task, surrounded by half-disassembled household projects.
Hal from Malcolm in the Middle tries to fix a lightbulb — click to watch on YouTube (Fox killed external embedding for this clip).

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…

The famous 'WTFs per minute' code-quality cartoon: a calm reviewer in the good-code cubicle, an apoplectic reviewer in the bad-code cubicle yelling WTF.
The only valid measurement of code quality.

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.

GatemavenLocal()'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:

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:

  1. <artifactId>-<version>.jar — empty-classifier file at the Maven-conventional path. Satisfies the DefaultMavenLocalArtifactRepository.java#L121 validator gate above. Maven-style consumers that don't read GMM still resolve it.
  2. <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:

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.