Skip to content

Split releases per plane; publish each fat jar once; trim embedded cruft - #122

Merged
jbachorik merged 15 commits into
mainfrom
fix/release-published-size
Oct 4, 2026
Merged

jbachorik merged 15 commits into
mainfrom
fix/release-published-size

Conversation

@jbachorik

Copy link
Copy Markdown
Collaborator

What & why

Maven Central storage showed ~180 MiB per release across the month's three releases. Measured root causes, each fixed:

  1. Every shadow-jarred module published its fat jar twice. The vanniktech maven-publish plugin adds the shadow jar under the all classifier while each module's own afterEvaluate override re-added the same file as the main artifact - byte-identical copies landed twice per artifact (verified by Central md5). ~77 MiB pure waste per release. Publication now carries exactly one jar per module: thin main + sources + javadoc; the all classifier survives only where it is a genuinely different artifact.

  2. The shell jar carried the whole Anthropic Java SDK (46 of 96 MiB unpacked, dragging okhttp/okio/kotlin). The Anthropic backend now ships as its own artifact, cataloged (anthropic) and installed at first explicit use - the same "Downloading from Maven repositories..." flow the JFR backends already have; auto never installs anything. jafar-shell: 38.2 -> 9.3 MiB.

  3. The OpenAI-compatible backend (openai/ollama, no SDK) also moved out and publishes as llm-openai; both catalog entries point at it.

  4. The MCP server jar was 51% fastutil (16k classes for 9 used types). The heap tools now depend on fastutil-core and the embedded copy is trimmed to the exact static closure derived at build time: jdeps over the runtime classpath, then a loading fixpoint (every kept class is loaded and its super/interfaces/field/method types walked) - jdeps alone misses supertypes and descriptor-only types, which two runtime NoClassDefFoundErrors proved. No keep-list ships; the derivation re-runs on every classpath change and fails the build on a missing class. Fat jar: 38.2 -> 13.2 MiB. jline (never referenced by anything the server loads) and the Maven-resolver stack (the plugin machinery now lazy-initializes; a server that never installs plugins never loads it) are excluded too.

  5. Javadoc jars were ~90% javadoc's bundled web fonts (3.91 of 4.18 MiB for jfr-mcp). --no-fonts for every subproject. Backends went 3.96 -> ~0.13 MiB, which were mostly docs.

  6. Releases split into planes, each with its own tag shape, version line and publish set: vX.Y.Z = shell (jafar-shell + the jfr-shell-* backends), core/ = parser+tools+gradle-plugin (also tags go-parser/vX.Y.Z), mcp/ = jfr-mcp, llm-anthropic/, llm-openai/. Version derivation (gradle/version-from-tag.gradle) is per-plane and stays the single source of truth (root build, all subprojects, the included gradle-plugin build); scripts/derive-version.sh --plane is the shell mirror; scripts/release.sh [plane] <type> applies per-plane branch policy (the shell keeps its release/X.Y._ stabilization lines; the other planes tag from the default branch). The release workflow derives the plane from the tag, publishes only that plane's artifacts, moves only that plane's jfr-shell-plugins.json entries (per-plugin jq, never a mass-bump) and JBang-catalog aliases, and tags the Go module on core releases. Fat jars record Embedded-Modules in their manifest so an app's version says which parser/SPI it embedded. Docs: README + doc/agents/Release.md + RELEASING.md describe the whole story.

Per-release Maven Central cost, measured: 171.2 -> ~54 MiB.

How verified

Gate Command Result
Full gate ./gradlew test shadowJar spotlessCheck --continue at HEAD failure set identical to pristine main baseline, by name (R6); spotless green
Publication shape publishToMavenLocal (throwaway key) for all 7 published artifacts one main jar each (+ the deliberate all on llm-anthropic); no duplicates
Real-artifact install flow built jar, set llm.backend=ollama, as-query "Backend not found / Downloading... done.", 2 jars installed, llm status = READY (loopback)
Four-format e2e :jfr-mcp:endToEndTest green on the trimmed jar; heap workflow included; caught both incomplete trims before commit
Per-plane version derivation scratch plane tags; ./gradlew per module; derive-version.sh --plane; release.sh --dry-run exact-on-tag = release version; dev = -SNAPSHOT; branch guards fire

Coverage notes

The e2e suite exercises all four formats through the real jar (the net for the fastutil trim); the plugin-system tests cover installer dep-resolution, catalog fallback usage, classloader scanning, and discovery through the plugin loader (a test that fails on the old code). The javadoc flag is verified by the generated docs having zero woff entries.

Not verified / known limitations

  • NOT VERIFIED: an actual Maven Central upload with the new publications (needs release secrets/staging) | the next tagged release; artifact-list structure matches today's observed Central layout exactly.
  • NOT VERIFIED: release.sh success paths (only dry-runs; the branch guards require a main/release-branch checkout) | the next real release.
  • jfr-mcp's remaining 13.2 MiB: jetty (the --attach SSE bridge) + the MCP SDK's jackson/reactor/schema stack are by design; gson stays (installed.json parsing is load-bearing).
  • The jbang-catalog scheduled sync still keys on the single latest release (it only served shell aliases; per-plane updates happen in release.yml).
  • Pre-existing, unrelated: the suite's recording-dependent failures (parser/red), LlmConfigTest's env leak, flaky McpJfrTransportTest port test - same names on pristine main.

Breaking changes / migration

  • Behavior: no LLM backend is bundled with the shell; ask installs a backend at first explicit use (documented in doc/cli/LlmSetup.md, air-gapped recipe included). Users must allow a Maven download on first ask, or install from files.
  • Consumers pulling io.btrace:* by exact version: planes version independently now; core/vX.Y.Z no longer implies a same-numbered shell release.
  • The release process: tags are per-plane; scripts/release.sh takes an optional plane argument (shell default preserves old behavior).

The vanniktech maven-publish 0.35 plugin adds the shadow jar to the publication under the
'all' classifier, and each module's own afterEvaluate override then re-added the same jar as
the main (classifier '') artifact. Both copies were uploaded, byte-identical: measured on
Maven Central, 0.28.0 stored ~77 MiB per release of the same bytes twice (jafar-shell and
jfr-mcp each ~80 MiB in total, mostly the duplicated fat jar). Central artifacts are
immutable, so the already-published releases cannot be corrected going back.

Verified: publishToMavenLocal for each module produces exactly one jar artifact (thin main
jar, sources, javadoc, pom) and the artifact dump shows three jar entries instead of four;
jar contents unchanged for jfr-mcp (38.2 MiB, still bundles the unimi/jetty stack - separate
slimming piece).
…ugh the plugin loader

The plugin installer downloaded exactly one jar per plugin and silently assumed that jar is
dependency-free (true for the JFR backends, whose only dependency is the shell's own classpath).
Any plugin that depends on a real library would fail at first use with NoClassDefFoundError,
which is exactly what installing a thin LLM backend plugin needs. The installer now collects the
dependency graph of the plugin's POM via aether - compile and runtime scopes (test/provided/system
stay out of plugin storage), nearest-wins conflict resolution, BOM-managed versions honored - and
copies every dependency jar under <pluginVersionDir>/deps/, which the plugin classloader already
scans. Backend discovery for the LLM SPI follows the JFR backend registry's pattern: the
plugin-aware classloader when the plugin system is initialized, the thread-context loader as the
test fallback. The bundled plugin catalog is now actually consulted (PluginRegistry.get used to
ignore it - the advertised offline fallback was dead data) and a no-network canInstallOffline
exists for ask-time gating; local Maven scanning recognizes llm-* artifacts. Local plugin
installs accept both backend SPIs; the PluginManager-singleton tests run in their own JVM
(:shell-core:pluginSystemTest) with task-provided fixture directories.

Verified: :shell-core:test and :shell-core:pluginSystemTest green (scope/conflict/BOM fixture
test, installer end-to-end fixture test, classloader-scan test, discovery-through-plugin-loader
test failing on the old discover()); runtime check of the real resolver shape returned 29 jars
for com.anthropic:anthropic-java:2.34.0 from Maven Central.
…ing its SDK

The jafar-shell release jar carried the whole Anthropic Java SDK plus okhttp/okio/kotlin:
the SDK alone outweighed the rest of the jar (9.3 of 38.2 MiB is everything except the SDK).
The backend now ships as its own artifact - a thin jar with a real POM (the installer resolves
its dependency graph, the previous commit's machinery) and a self-contained jar under the
'all' classifier for air-gapped --install-plugin installs. It is cataloged as 'anthropic' in
jfr-shell-plugins.json and published by the release workflow alongside the other artifacts.
Selecting it explicitly (set llm.backend = anthropic) installs it on the spot at the first
ask, mirroring the JFR backend selection flow; automatic selection never installs anything;
llm status lists what is installed. The no-backend remedy no longer points at a module that
has never existed (llm-core).

Verified: built jafar-shell fat jar = 9.3 MiB with zero anthropic/okhttp/okio/kotlin entries;
end-to-end through the built jar: ask with the backend unset installed the plugin (35 jars
loaded), the ask then degraded to the credential remedy without any model call, and llm status
listed the installed backend; LlmCommandsTest updated (the shell must NOT bundle the backend
now) and green; all module tests match the pristine-main baseline failure set by name.
RELEASING.md and doc/agents/Release.md claimed jfr-shell is published to GitHub Packages and
that the release workflow triggers a JitPack build - neither exists anywhere in the build or
workflow history, and no release ever carried assets. The docs now state the actual channels
(Maven Central artifacts + the plugin catalog committed to main) and the complete manual-release
command list including llm-anthropic. doc/cli/LlmSetup.md documents the install-on-first-use
behavior for the anthropic backend and the air-gapped -all.jar recipe; doc/agents/Llm.md and
the changelog tell the same story.

Verified: the referenced Gradle tasks exist (parser/tools/jfr-shell/jfr-mcp/llm-anthropic
shadowJar + publishAllPublicationsToMavenCentralRepository ran against mavenLocal in the
verification runs); doc changes are text-only against the state checked in the previous
commits.
hdump-parser and hdump-shell ship 16,204 fastutil classes in every artifact that embeds them
(the MCP server jar carried 19.5 MiB of packed fastutil - 51 percent of the jar) while their
code uses nine types, all maps, sets and lists over long and int keys. it.unimi.dsi:fastutil-core
is that exact subset, published by fastutil itself; for 8.5.12 it is 6.4 MB instead of 23 MB.
Every consumer of these modules is unpublished or embeds them, so this only trims embedded copies;
the heap parser's own tests plus the full four-format MCP end-to-end suite (heap open/summary/
query/report through the built jar) run green against the core artifact.

Verified: :hdump-parser:test, :hdump-shell:test green; jfr-mcp fat jar 38.2 -> 21.1 MiB with all
nine used fastutil types present; :jfr-mcp:endToEndTest green (real server, java -jar, all four
formats).
…erver

The PluginManager singleton built its Maven resolver, plugin registry, installer and update
checker eagerly, so every backend discovery loaded the whole aether stack - and the MCP server,
which never installs plugins, carried it in its jar (1.9 MiB packed of resolver, HTTP transports,
commons and gson dependencies) merely because the backend registry reaches for the plugin
classloader through this singleton. The collaborators now load on first registry/install/update
use; the resolver stack stays out of the jfr-mcp fat jar, as does the shells' jline terminal
stack (0.9 MiB) that the server never loads: the MCP tools use the engine classes - sessions,
path evaluators, report generators - which import no jline, and the TUI adapters implementing
ShellModule.getCompleter are never initialized there. The MCP jar drops from 38.2 MiB (0.26.0
through 0.29.0 releases) to 18.7 MiB.

jline is not referenced from any class the server touches (verified by the shell-level import
scan and jdeps over the server classes; the excluded commons-codec remnant is 30 KB).
The plugin machinery's laziness is covered by the PluginManager-singleton tests and by the
real-artifact flow: a fresh jafar-shell with no backends auto-installs the default JFR backend
(5 jars), then an explicitly selected anthropic backend installs at first ask (35 jars),
degrading to the credential remedy with no model call.

Verified: :shell-core:test, :shell-core:pluginSystemTest, :jfr-mcp:test, :jfr-mcp:endToEndTest
green; spotlessCheck green; real-artifact install flow re-run against mavenLocal succeeds.
…lly loads

The fastutil-core swap left 16,204 shipped classes for the 17 reachable ones; the jdeps closure
of the embedded code's references is the exact set that can ever load - fastutil ships pure
collections with no reflection and the heap code invokes it through direct imports only - so
the MCP fat jar embeds just that closure, derived at build time rather than from a checked-in
list: the derivation re-runs on every classpath change, loads each kept class with initialization
suppressed, and closes over superclasses, interfaces, field types and method signatures, so a
newly referenced fastutil type can never ship missing; a name absent from the core jar fails the
build, not hdump_open. The build-time jdeps walk found what a body-reference-only closure
misses twice: jdeps does not report superclass chains at all (a class cannot load without them:
NoClassDefFoundError in hdump_open) and does not resolve types appearing only in field or method
descriptors (NoClassDefFoundError in hdump_summary). MCP fat jar: 21.1 -> 13.2 MiB; the embedded
fastutil copy goes 16,204 classes / 14.6 MiB to the 169-class / 2.1-MiB closure.

The E2E harness now includes the server's stderr in the tool-error failure message, matching the
other assertions; it is what surfaces the failing class in a NoClassDefFoundError.

Verified: :jfr-mcp:test, :jfr-mcp:endToEndTest (all four formats through the trimmed jar, heap
workflow included), :shell-core:test, :shell-core:pluginSystemTest, :hdump-parser:test,
:hdump-shell:test, :jfr-shell:test, :llm-anthropic:test green; full gate
./gradlew test shadowJar spotlessCheck --continue matches the pristine-main baseline failure set
by name; spotlessCheck green.
Every published artifact's javadoc jar was ~90% javadoc's own bundled DejaVu web fonts: the
jfr-mcp jar's 4.18 MiB held 3.91 MiB of woff/woff2 for a few hundred HTML pages of a handful of
classes, and the 0.1-MB backend plugins carried matching four-miB docs. Generated documentation
does not need the fonts shipped: --no-fonts (set for every subproject's Javadoc task; gradle
prefixes option names with a single dash, so the name carries the dash of --no-fonts itself)
keeps the HTML and the search index and drops the fonts.

Per-release Maven Central cost, measured against mavenLocal republish of every artifact:
171.2 MiB -> 53.8 MiB (jafar-shell 80.9 -> 9.9, jfr-mcp 80.5 -> 13.6, backends 8.0 -> 0.3,
llm-anthropic new at 29.0 for its self-contained air-gap jar, dedup from the earlier commits).

Verified: publishToMavenLocal for jfr-shell, jfr-mcp, llm-anthropic, jfr-shell-jdk,
jfr-shell-jafar, jafar-parser, jafar-tool; javadoc jars measured 0.1-0.4 MiB with zero woff
entries; :jfr-mcp:javadoc runs the flag with zero woff files in build/docs/javadoc;
spotlessCheck green.
…ndle both LLM backends

llm-openai (the openai and ollama profiles, one small JDK-HttpClient adapter with no provider
SDK) joins llm-anthropic as a standalone artifact; both shells drop their backend
runtimeOnly lines, so the release jar bundles no LLM backend at all, just the SPI in
shell-core. The catalog gains openai and ollama entries pointing at llm-openai (same artifact,
two backends), and the release workflow publishes it next to llm-anthropic. The ask-time
install flow is unchanged and now covers all three backend ids; a status with nothing installed
tells the user how to install.

Fat jars also record Embedded-Modules in their manifest now: the fat jar is a snapshot of its
dependencies, so its own version does not say which parser or SPI it runs; the manifest's
io.btrace module list (resolved at manifest-merge time, so nothing resolves during
configuration) does. unzip -p jar META-INF/MANIFEST.MF | grep Embedded-Modules.

Verified: the new shell artifact bundles zero backend classes and 32 SPI classes; real-artifact
run: fresh plugin dir, set llm.backend = ollama, as-query prints 'Backend not found /
Downloading from Maven repositories... done.' and installs llm-openai with its POM-resolved
dependencies (2 jars), then llm status lists ollama READY against the loopback endpoint; a
second run executed one completion against the configured Ollama Cloud endpoint (the user's own
setting, one request - the query path works end to end); :jfr-shell:test green (tests updated
for the no-backend-by-default world), :llm-openai:test, :llm-anthropic:test,
:jfr-mcp:test, :jafar-shell:compileJava, :shell-core:pluginSystemTest green; spotlessCheck
green.
One version line for the whole repo re-published every artifact at every tag - the LLM plugin's
29 MiB air-gap jar went to Maven Central for a parser-only fix too. The release now splits into
planes, each with its own tag shape, version line and publishing scope. The shell plane keeps
the original vX.Y.Z tags (its whole history); the other planes follow the Go module's
component/vX.Y.Z shape: core/ (jafar-parser, jafar-tools, jafar-gradle-plugin), mcp/ (jfr-mcp),
llm-anthropic/, llm-openai/. Coupling rules: self-contained planes release independently and
their fat jars record Embedded-Modules in the manifest so the user knows which parser and SPI
they got; a release-API breaking shell-core change tags every SPI-consuming plane the same day,
each with its own number; the Go module versions with the core plane (the parser-parity rule).

gradle/version-from-tag.gradle: the plane map and per-plane derivation are the single source of
truth; a plane without tags of its own rides the newest bare vX.Y.Z number until its first tag
lands. scripts/derive-version.sh gains --plane as the shell mirror. scripts/release.sh takes
the plane as its argument; the shell keeps the release/X.Y._ stabilization-line policy
(minor/major from the default branch, patches from the release branch), every other plane tags
from the default branch - patches included, no stabilization lines for libraries. The release
workflow derives the plane from the tag, publishes only that plane's artifacts, moves only that
plane's plugin-catalog and JBang-catalog entries, and tags the Go module on core-plane releases;
the release notes match the tag-shaped changelog section with a fallback to the bare version of
the pre-split layout. The dead jar-glob in create-release is gone: the checkout builds nothing,
so it silently matched nothing on every release ever, and the published channel is Maven
Central.

Verified: scratch plane tags on a detached head derive per-plane versions (gradle + script);
exact-on-tag builds report the release version; release.sh --dry-run rejects wrong-branch
combos and derives the right tag per plane; the success paths in release.sh NOT VERIFIED:
running them needs the main checkout (the branch is checked out in the user's tree) - the
tag/push/release-create flow runs for real on the next release. The full gate
(./gradlew test shadowJar spotlessCheck --continue) matches the pristine-main baseline failure
set by name; every module's version derives green with the scratch tags deleted
README.md gains a Releasing section: the plane table (tag shapes, what each publishes, the
cost that motivates the split), the release command, the Embedded-Modules manifest line, and
the version-derivation one-liner; the stale 'v0.11.0' status line becomes the 0.x line it is.
doc/agents/Release.md is rewritten as the agent-facing procedure: the plane table, the
release steps the agent owes (changelog section keyed by tag, dry-run, release), the coupling
rules that matter when an agent edits code (self-contained planes release independently;
breaking SPI changes tag every consuming plane; the Go module moves with core; never
mass-bump or downgrade catalog entries), the post-release nothing-to-do, the verification
owed, and the emergency per-plane manual publish. RELEASING.md's quick steps, changelog
example, the automated-behavior list and the catalog-version rule all match the implemented
machinery now.

Verified: derive-version.sh --plane and release.sh --dry-run run as documented (branch guards
fire off-branch as designed, version derivation returns per-plane numbers); docs contain no
references to the never-existing GitHub Packages publish or the JitPack trigger; the AGENTS.md
map already routes release questions to doc/agents/Release.md
@github-actions

github-actions Bot commented Oct 4, 2026 •

Copy link
Copy Markdown
Contributor

Combined JUnit Test Report

  • Total: 2307
  • Passed: 2291
  • Failures: 0
  • Errors: 0
  • Skipped: 16

HTML Test Reports

Run artifacts: https://github.com/btraceio/jafar/actions/runs/37202374413

Groovy cannot pick between the ClassLoader and AccessControlContext overloads when the parent
argument is an untyped null on the CI's JVM; the trim task failed there while it ran locally.
Cast the parent argument to ClassLoader.
The --no-fonts javadoc flag failed on CI: parser-codegen's javadoc rejects it (mode-dependent;
release-8 javadoc runs cannot take it), which broke the mavenLocal publish the plugin tests
depend on. Excluding resource-files/fonts/** from javadoc-classed Jar tasks instead keeps the
effect (no fonts shipped in any module's documentation) regardless of how javadoc was invoked;
the javadoc invocation itself stays untouched.

Verified: :parser-codegen:javadoc, :parser-core:javadoc, :jfr-mcp:javadoc, :tools:javadoc green
with zero woff entries in the packaged jars; spotlessCheck green.
@jbachorik
jbachorik merged commit 0f2cbcc into main Oct 4, 2026
4 checks passed
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