feat(publishing): publish only the artifacts that changed - #236
Open
kitakkun wants to merge 8 commits into
Open
Conversation
Contributor
There was a problem hiding this comment.
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-bomandjetwhale-catalogas 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
force-pushed
the
light-weight-publishing
branch
from
August 6, 2026 15:51
2e863a9 to
6378bf3
Compare
…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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.propertiesrecords the version each artifact currently resolves to../gradlew prepareReleaserewrites it: artifacts that changed move to the release ("train") version, unchanged ones keep theirs.RELEASING.mdhas 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.tomldiff that only touches thejetwhaleline 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-bomandjetwhale-catalogare 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, sinceplugins { }needs a literal version.Notes for review
project.versionis untouched. Only the publication coordinates change. Gradle reads the target project'sMavenPublication— 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,.moduleandavailable-atentries.jetwhale-agent-gradle-plugingenerated the compiler plugin's coordinates ingetPluginArtifact()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.jetwhale-qa-agentare resolved by the host's version rather than their own (OfficialPluginCatalog,runJetWhaleQaAgent'sqaAgentVersiondefault), so a gap there is a 404 for users. All are JVM-only and cheap.release.gradle.kts, because the root build cannot enumerate a separate build. A new module in the root build needs no wiring beyond thepublishconvention.swiftPMDependenciesForLockFilesMetadataClasspathDependenciesis 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-alpha11republishes 22 of 22, since 12 artifacts are new andlibs.versions.tomlchanged beyond the train version. The saving starts at1.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 buildall pass1.0.0-alpha10artifacts — identicalmetadataCommonMainCompileClasspath,jvmCompileClasspath,jsCompileClasspath,iosArm64CompileKlibrariesandlinuxX64CompileKlibrariesjetwhale.agent.runtime,jetwhale.plugins.host,jetwhale.versions.jetwhale) resolved from the same consumerverifyReleasePlancorrectly rejects a lock file missing the new artifacts, and rejects an artifact staying behind while a dependency movesOut of scope
Recorded in
RELEASING.mdand 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.