Skip to main content
Version: Next

A Gradle library and its version catalog

You can release a published JVM library, an app that consumes it, and their shared gradle/libs.versions.toml file in one run. They all move to the same new version together.

The layout​

gradle/libs.versions.toml the shared version catalog, at the repository root
libs/core/build.gradle.kts com.acme:core, published with maven-publish
apps/app/build.gradle.kts the Android app that depends on it
dispat.json

The configuration​

dispat.json
{
"scripts": {
"pin-catalog": "dispat writer gradle/libs.versions.toml --set com.acme:core=$DISPAT_WORKSPACE_CORE_VERSION",
"publish-lib": "./gradlew :libs:core:publish -Pversion=$DISPAT_NEW_VERSION",
"build-app": "./gradlew assembleRelease"
},
"run": {"beforeAll": "pin-catalog"},
"spaces": {
"libs": {
"path": "libs",
"flow": {"publish": "publish-lib"},
"autoVersion": {
"enabled": true,
"manifests": "none",
"replace": [
{"files": ["build.gradle.kts"], "find": "version = \"{previous}\"", "write": "version = \"{version}\""}
]
}
},
"apps": {
"path": "apps",
"flow": {"build": "build-app"},
"autoVersion": {"enabled": true, "range": "exact"}
}
},
"packages": {
"core": {"manifestNames": ["com.acme:core"]}
}
}

Three lines carry most of the weight. Each has a specific purpose.

manifestNames. Set this to connect your package folder to its Gradle coordinate. A Gradle build script declares dependencies as coordinates like com.acme:core, but your package sits in a folder called core. Stating the coordinate tells dispat to recognize a declaration anywhere in the repository as this package.

range: exact. Set this because a Gradle coordinate takes a plain version. The default policy writes ^1.3.0. That is npm's spelling, and it means nothing to Gradle.

The replace rule. Use a literal find-and-write because a top-level version = "1.2.0" in build.gradle.kts is a plain Kotlin assignment. It is not a manifest field, so the parsing strategy has nothing to grip. The run fills in {previous} and {version} automatically.

A release​

$ git commit -m "feat(core)^: coroutine-friendly api"
$ dispat
12:46:52 INF release started root=.
12:46:52 INF ● changed baselineFromInitials=true bump=minor channel=stable dueToProviders=[] ownCommits=1 package=core reason=direct space=libs version="1.2.0 -> 1.3.0"
12:46:52 INF ● changed baselineFromInitials=true bump=patch channel=stable dependsOn=["core"] dueToProviders=["core"] ownCommits=0 package=app reason="propagated from core" space=apps version="0.4.1 -> 0.4.2"
12:46:52 INF release plan ready held=0 packages=2 releasing=2
12:46:52 INF gradle/libs.versions.toml stage=beforeAll
12:46:52 INF applied dependencies com.acme:core 1.3.0 stage=beforeAll
12:46:52 INF 1 manifest(s): 1 applied, 0 skipped, 0 missing stage=beforeAll
12:46:52 INF file reconciled file=build.gradle.kts occurrences=1 package=core stage=version version=1.3.0
12:46:52 INF version succeeded package=core stage=version version=1.3.0
12:46:52 INF publish started package=core stage=publish version=1.3.0
12:46:52 INF ./gradlew :libs:core:publish -Pversion=1.3.0 package=core stage=publish version=1.3.0
12:46:52 INF manifest reconciled manifest=build.gradle.kts package=app ranges=1 stage=version version=0.4.2 versionWritten=true
12:46:52 INF version succeeded package=app stage=version version=0.4.2
12:46:52 INF build started package=app stage=build version=0.4.2
12:46:52 INF build succeeded package=app stage=build version=0.4.2
12:46:52 INF published package=core stage=publish tag=core@1.3.0 version=1.3.0
12:46:52 INF published package=app stage=publish tag=app@0.4.2 version=0.4.2
12:46:52 INF done cancelled=0 failed=0 held=0 published=2 skipped=0 took=0.5s unchanged=0

Look at ranges=1 in the output for the app. This shows the coordinate in its dependencies { } block moving to com.acme:core:1.3.0. The file reconciled log on the library means the replace rule wrote version = "1.3.0" into its build script.

The catalog belongs to the repository rather than to any one package. It moves once during the beforeAll stage.

[versions]
acme-core = "1.3.0"
okhttp = "4.12.0"

[libraries]
acme-core = { module = "com.acme:core", version.ref = "acme-core" }
okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" }

Notice that the [libraries] entry remains untouched. dispat followed the version.ref to the [versions] table and wrote the number where it actually lives. This is the only edit that preserves the reference.

What dispat reads and writes​

$ dispat scanner
apps/app/build.gradle.kts gradle com.acme.app@0.4.1 build 41
dependencies com.acme:core 1.2.0
gradle/libs.versions.toml gradle
dependencies com.acme:core 1.2.0
dependencies com.squareup.okhttp3:okhttp 4.12.0
libs/core/build.gradle.kts gradle
3 manifest(s), 3 dependency declaration(s)

In a catalog, dispat reads both spellings of a library: the group:artifact:version shorthand string and the table form with module or group plus name. It resolves a version behind version.ref through [versions] on the way in and writes it back there on the way out. Bundles and plugins are left alone because they are not dependencies.

In a build script, dispat treats a literal coordinate in the dependencies { } block as a dependency. A catalog accessor like libs.okhttp is not a dependency here, because the version lives in the catalog where dispat edits it. It reads a project dependency like project(":core") as a link to that module.

Identity comes from applicationId, falling back to namespace. dispat uses versionName as the version and versionCode as a build counter. A library module that declares neither has no identity of its own, like libs/core above, which is exactly why manifestNames is in the configuration.

Worth knowing​

  • versionCode is never written by a version write. It is a counter rather than a version, so it moves once per build regardless of the release. The command to move it insists on an integer, so provide one when you run dispat writer <file> --set-build "$GITHUB_RUN_NUMBER".
  • One [versions] entry shared by two libraries cannot take two different versions. Give each library its own entry. If two edits land on the same version.ref, dispat refuses the write rather than silently picking one.
  • A version assembled from properties is left alone. An assignment like version = project.property("acmeVersion") is an intentional indirection. Point a replace rule at gradle.properties instead.
  • The catalog is at the repository root, outside every package folder. List it under commit.include. This ensures the release commit carries the change made in beforeAll.

See also​

Test repository resolution​

Keep a clean consumer check against packed files or a staging repository for the coordinates, Gradle metadata and catalog. A build using local project dependencies does not establish that a separate checkout can resolve the same release. Check the production registry separately after native records exist. See integration patterns.

Kotlin multiplatform releases need an explicit artifact inventory. Check the BOM, JVM artifacts and required platform variants before marking a release complete; one successful coordinate is not the whole publication set. Point versioning at the input the publisher actually uses, which may be a release property rather than a value kept in the source tree. Use a version-stage script for that property when appropriate.

Verify Android coordinates and AARs​

Android libraries published by Gradle need the same remote-consumer boundary. Verify every included publishable module from a clean Gradle consumer. Directories excluded from settings.gradle are not missing artifacts, and dispat cannot infer publication intent from a dormant build.gradle file.

Compile against the published POM and AAR​

Artifact presence alone does not prove that its declared dependencies are sufficient. dispat can order Gradle builds and publish commands and reconcile literal coordinates, but it does not inspect JVM bytecode or compare an AAR with its POM. Add a clean Kotlin consumer compile for the exact remote coordinate to the release gate. A bytecode-to-metadata scan can catch the missing annotation dependency before publication; the clean consumer build verifies the resolver behavior that users receive.