Skip to content

feat(publishing): publish only the artifacts that changed - #236

Open
kitakkun wants to merge 8 commits into
mainfrom
light-weight-publishing
Open

feat(publishing): publish only the artifacts that changed#236
kitakkun wants to merge 8 commits into
mainfrom
light-weight-publishing

Conversation

@kitakkun

@kitakkun kitakkun commented Aug 6, 2026

Copy link
Copy Markdown
Owner

Maven Central introduced publishing limits enforced from 2026-10-01, measured as three-month averages of monthly file count, release size and release count. File count is what binds here: a Kotlin Multiplatform module publishes one Maven module per target, so the 11 multiplatform artifacts alone account for roughly 2,500 of the ~2,700 files a full release uploads — and until now every release republished all of them under one shared version, whether or not anything changed.

This makes a release publish only the artifacts whose sources moved, and gives consumers a way to name a release without tracking each artifact's version by hand.

How it works

gradle/published-versions.properties records the version each artifact currently resolves to. ./gradlew prepareRelease rewrites it: artifacts that changed move to the release ("train") version, unchanged ones keep theirs. RELEASING.md has the full procedure.

An artifact is republished when its directory changed, when a build-configuration input changed, when a published artifact it depends on moved, or when it is in the always-republished set. Notably a gradle/libs.versions.toml diff that only touches the jetwhale line is not treated as global — every release bumps it, so treating that as a global change would make every release a full one and defeat the whole scheme.

jetwhale-bom and jetwhale-catalog are new, generated from the same file. The catalog is the documented route: it resolves at settings time, outside the variant matching KMP targets rely on, and it is the only one that can carry the Gradle plugins, since plugins { } needs a literal version.

Notes for review

  • project.version is untouched. Only the publication coordinates change. Gradle reads the target project's MavenPublication — not its version — when generating a dependent's POM, so the recorded versions propagate on their own. Verified: editing one lock entry moves it through every dependent's POM, .module and available-at entries.
  • A pre-existing version-skew bug is fixed here. jetwhale-agent-gradle-plugin generated the compiler plugin's coordinates in getPluginArtifact() from its own version, which only worked while the two always shared one. It now takes them from the recorded version that publishes that artifact, and the release plan republishes the Gradle plugin whenever the compiler plugin moves.
  • Always-republished set: the BOM and catalog describe a release; the official plugins and jetwhale-qa-agent are resolved by the host's version rather than their own (OfficialPluginCatalog, runJetWhaleQaAgent's qaAgentVersion default), so a gap there is a 404 for users. All are JVM-only and cheap.
  • Included builds are listed by hand in release.gradle.kts, because the root build cannot enumerate a separate build. A new module in the root build needs no wiring beyond the publish convention.
  • KGP gotcha: swiftPMDependenciesForLockFilesMetadataClasspathDependencies is a dependency-scope configuration with the same role flags as a real one, but KGP fills it with every project in the build. Scanning it would make each module look as though it depended on all the others and force a full release every time, so it is excluded by name.

Effect

The next release is unaffected — 1.0.0-alpha11 republishes 22 of 22, since 12 artifacts are new and libs.versions.toml changed beyond the train version. The saving starts at 1.0.0-alpha12: a release touching only leaf modules drops to roughly 300 files instead of ~2,700.

Snapshots are unchanged — they ignore the lock file and stay one self-consistent set.

Verification

  • ./gradlew check, spotlessCheck, -p jetwhale-agent-plugin check, -p jetwhale-gradle-plugin build all pass
  • Generated POMs and Gradle Module Metadata compared against the published 1.0.0-alpha10 artifacts — identical
  • BOM resolved from a scratch KMP consumer for metadataCommonMainCompileClasspath, jvmCompileClasspath, jsCompileClasspath, iosArm64CompileKlibraries and linuxX64CompileKlibraries
  • Catalog aliases (jetwhale.agent.runtime, jetwhale.plugins.host, jetwhale.versions.jetwhale) resolved from the same consumer
  • verifyReleasePlan correctly rejects a lock file missing the new artifacts, and rejects an artifact staying behind while a dependency moves

Out of scope

Recorded in RELEASING.md and worth revisiting if the headroom is not enough: trimming KMP targets (~140 files per target per full release), snapshot publishing frequency, and dropping md5 checksums.

@kitakkun
kitakkun marked this pull request as ready for review August 6, 2026 15:29
Copilot AI lite review requested due to automatic review settings August 6, 2026 15:29

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Implements “changed-only” Maven Central publishing by introducing a per-artifact version lock (gradle/published-versions.properties) plus release planning/verification tasks, and adds a BOM + published version catalog so consumers can still depend on a single “release version” even when individual artifacts drift.

Changes:

  • Add release planning (printReleasePlan / prepareRelease) and gating (verifyReleasePlan) to publish only artifacts whose recorded version matches the train version.
  • Introduce jetwhale-bom and jetwhale-catalog as the consumer-facing entry points for a release’s resolved artifact set (including plugin aliases via the catalog).
  • Update CI/release docs and consumer docs to use the catalog/BOM, and update publishing workflow to publish included builds conditionally.

Reviewed changes

Copilot reviewed 30 out of 31 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
settings.gradle.kts Includes the new :jetwhale-bom and :jetwhale-catalog modules in the build.
RELEASING.md Documents the new release procedure and how “changed-only” publishing is computed.
README.md Updates the Maven Central badge to point at jetwhale-bom as the release entry point.
jetwhale-catalog/build.gradle.kts Adds a published Gradle version catalog that maps a release to per-artifact/plugin versions.
jetwhale-bom/build.gradle.kts Adds a published BOM that constrains the per-artifact versions for a release.
jetwhale-agent-plugin/jetwhale-agent-gradle-plugin/build.gradle.kts Fixes version-skew by sourcing the compiler plugin coordinates from recorded published versions.
gradle/published-versions.properties Introduces the lock file recording per-artifact resolved versions and previous train tag.
gradle/libs.versions.toml Adds the root-only release convention plugin alias.
gradle-conventions/src/test/kotlin/util/ReleasePlannerTest.kt Adds unit tests for release planning, verification, and lockfile parsing/rendering behaviors.
gradle-conventions/src/main/kotlin/util/VerifyReleasePlanTask.kt Adds a CI gate task to ensure the committed lock file describes a releasable state.
gradle-conventions/src/main/kotlin/util/ReleasePlanTask.kt Adds the task that computes/prints/writes the release plan by diffing against the previous tag.
gradle-conventions/src/main/kotlin/util/ReleasePlan.kt Defines the publishable-module model plus release planning and verification logic.
gradle-conventions/src/main/kotlin/util/PublishModules.kt Collects publishable modules and their project-dependency graph for planning/verification.
gradle-conventions/src/main/kotlin/util/PublishedVersions.kt Implements reading/parsing/rendering and version lookup for the per-artifact lock file.
gradle-conventions/src/main/kotlin/release.gradle.kts Adds the root “release” convention: plan/prepare/verify tasks and aggregate publish task.
gradle-conventions/src/main/kotlin/publish.gradle.kts Changes published coordinates to use recorded per-artifact versions and wires “publish changed” aggregation.
gradle-conventions/build.gradle.kts Adds test dependencies and enables JUnit platform for convention tests.
docs/guide/network-inspector.md Switches dependency examples to catalog aliases and links back to catalog/BOM setup.
docs/guide/nav3-navigator.md Switches dependency example to catalog alias.
docs/guide/getting-started.md Adds catalog setup and BOM alternative; updates plugin application example to catalog alias.
docs/guide/developing-plugins.md Adds catalog setup and updates plugin/dependency examples to catalog aliases.
docs/guide/compose-semantics-inspector.md Switches dependency examples to catalog aliases.
compat-test/run-matrix.sh Clarifies that the script’s version argument is a “release version” resolved via the BOM.
compat-test/README.md Documents BOM-based mapping from release version to the runtime artifact’s actual published version.
compat-test/build.gradle.kts Updates compat test dependencies to use the BOM for release-version mapping.
build.gradle.kts Applies the new root-only release convention plugin.
AGENTS.md Records the new release approach and the lock file responsibilities for contributors/agents.
.github/workflows/test.yml Runs gradle-conventions tests explicitly since root check doesn’t cover included builds.
.github/workflows/publish.yaml Adds verifyReleasePlan, publishes only changed root artifacts, and conditionally publishes included builds.
.github/scripts/publish-included-build.sh Adds helper to publish included builds only when their artifacts are due in this train.
.claude/skills/bump-dependencies/SKILL.md Updates dependency-bump checklist to validate the release plan behavior on Kotlin/Compose bumps.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +145 to +156
private fun git(vararg arguments: String): String {
val output = ByteArrayOutputStream()
exec.exec {
commandLine(listOf("git") + arguments)
workingDir = repositoryRoot.get().asFile
isIgnoreExitValue = true
standardOutput = output
errorOutput = ByteArrayOutputStream()
}
return output.toString()
}
}
build_dir="${1:?usage: publish-included-build.sh <buildDir> <artifactId>...}"
shift

train="$(grep -m1 '^jetwhale = ' gradle/libs.versions.toml | cut -d'"' -f2)"
for artifact_id in "$@"; do
# A never-published artifact has no entry, which reads as "not at the train version" — but it is
# exactly what a release must publish, so treat a missing entry as due.
recorded="$(grep -m1 "^artifact.${artifact_id}=" gradle/published-versions.properties | cut -d= -f2 || true)"
Maven Central now bills publishers on monthly file count, and a full release
uploads ~2,700 files — most belonging to artifacts that did not change, because
every release republished everything under one shared version.

gradle/published-versions.properties records the version each artifact
currently resolves to, and the `publish` convention passes it to `coordinates()`
instead of the release ("train") version. `project.version` is left alone: jar
names and the host app's Conveyor version derive from it, and Gradle reads the
target project's MavenPublication — not its version — when generating a
dependent's POM, so the recorded versions propagate on their own.

`prepareRelease` works the plan out by diffing each module against the tag named
by `previous.train.version`, a recorded value rather than a `git describe` guess
because snapshot pre-releases also leave tags behind. Changes to the build
configuration force a full release, but a `gradle/libs.versions.toml` diff that
only touches the `jetwhale` line does not — every release bumps it, so treating
that as global would make every release a full one. `verifyReleasePlan` is the
gate before publishing, and `publishChangedToMavenCentral` aggregates exactly
the artifacts that are due, which the vanniktech build service bundles into a
single Central Portal deployment.

The agent Gradle plugin generated the compiler plugin's coordinates from its own
version, which only worked while the two always shared one. It now takes them
from the same recorded version that publishes that artifact.

Snapshots ignore the lock file entirely and stay one self-consistent set.
With per-artifact versions, consumers can no longer write one version across
every dependency line. Both modules generate their contents from
gradle/published-versions.properties, so a release names itself once and maps
onto whatever combination it actually published.

The version catalog is the primary route: it resolves at settings time, outside
the variant matching Kotlin Multiplatform targets rely on, and it is the only one
of the two that can carry the Gradle plugins, since `plugins { }` needs a literal
version. The BOM covers Maven consumers and anyone who cannot use a catalog;
verified to resolve for metadata, JVM, JS and native targets.

Both are cheap — 20 files between them — and are published on every release.
`verifyReleasePlan` runs first and fails when the lock file does not describe a
releasable state — a release tagged without `prepareRelease` would otherwise
publish a BOM and catalog that omit whatever is missing.

The root build then publishes only the artifacts that are due. It cannot select
tasks in the `jetwhale-gradle-plugin` and `jetwhale-agent-plugin` builds, so
publish-included-build.sh makes that call from the same recorded versions and
skips the build when none of its artifacts move. A missing entry counts as due:
that is a never-published artifact, which is exactly what a release must upload.
Dependency snippets no longer spell out a version per artifact, because within
one release those versions differ. They go through the published catalog
instead, with the BOM documented as the alternative for builds that cannot use
one.

RELEASING.md is new and covers the release steps, how the changed set is worked
out, and which artifacts are always republished — the official plugins and the
QA agent are resolved by the host's version rather than their own, so a gap
there is a 404 for users. compat-test now resolves the BOM, which keeps
`-PjetwhaleVersion` meaning the release while the agent runtime may sit at an
older version. The Maven Central badge tracks the BOM for the same reason: any
single library would freeze at whatever release last changed it.
The plan logic decides what a release uploads, so a silent regression there is a
release that quietly omits artifacts. 18 tests cover the downstream closure, the
global-change rule, the always-published set, and each rejection
`verifyReleasePlan` makes. `check` on the root build does not descend into
included builds, so the workflow asks for them separately.

Two of them pin the `gradle/libs.versions.toml` rule in place: bumping only the
`jetwhale` line must not count as a global change, or every release becomes a
full release, while an entry that merely starts with `jetwhale` must.

The missing-tag message assumed the tags were not fetched. That is the usual
cause on a shallow CI clone, but `previous.train.version` naming a version that
was never released — a `-SNAPSHOT` pre-release tag, say — reads the same way, so
the message now names both.
…puts

Both of these failed in the direction that matters: quietly publishing less than
the release should.

`git()` discarded the exit code, so a failed `git diff` returned empty output —
indistinguishable from a clean diff, which reads as "the version catalog did not
change beyond the train version" and skips the full release a Kotlin bump needs.
It now fails on any unexpected exit code, with `rev-parse --verify --quiet`
opting into 1 because that is how it reports an unknown ref.

The included-build guard read the train version with a grep that assumed exact
spacing. A reformatted TOML produced an empty version rather than an error, which
matches no artifact and skips the build without publishing anything. The pattern
is whitespace-tolerant now, an unreadable version is a hard failure, and the
lock-file lookup no longer treats the `.` in `artifact.` as a wildcard.

Reported by Copilot on #236.
@kitakkun
kitakkun force-pushed the light-weight-publishing branch from 2e863a9 to 6378bf3 Compare August 6, 2026 15:51
…t move

`publish-included-build.sh` decided per build, not per artifact: if any artifact
of an included build was due, it ran `publishToMavenCentral` for the whole build.
`jetwhale-agent-plugin` holds two artifacts that move independently — the Gradle
plugin follows the compiler plugin, but not the reverse — so a release that
touches only the Gradle plugin re-uploaded the compiler plugin at the version it
already has. Central releases are immutable, so the publish job would fail.

Give every build the same `publishChangedToMavenCentral` aggregate the root build
already had, registered by the `publish` convention on first use. It exists even
when nothing is due, so the workflow can invoke it unconditionally, and the
script — along with its unescaped artifactId interpolation into a sed pattern —
is gone.
`alwaysPublishArtifactIds` was checked only against artifacts the lock file
already knew, so an id matching no module passed every check while republishing
nothing — the opposite of what the set is for. That set exists to keep
`OfficialPluginCatalog`'s one-click install off a 404, and this release cycle
renames `jetwhale-gradle-plugin` to `jetwhale-host-gradle-plugin`, so a stale id
is a live hazard rather than a hypothetical one. `prepareRelease` now refuses to
plan against an unknown id, and `verifyReleasePlan` reports it.

The same verification also reports a lock entry no module owns. `prepareRelease`
drops those by rewriting the file, but a hand-edited entry would otherwise offer
consumers a version through the BOM and the catalog that nothing ever publishes.
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.

2 participants