Publishing with Gradle

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Gradle 9.8.1 (the current release on 2026-10-16). Every example was run through the Gradle wrapper on macOS with JDK 26 (Oracle java version "26"), which is both the JVM that runs the daemon and the JDK that compiles the code; the code is compiled with options.release = 21, so the published metadata says "Java 21". The Kotlin DSL is shown first and the Groovy DSL second wherever the syntax differs, and both versions of the tidepool build and of every experiment were run with the same commands. The two DSLs produced identical POM and module files (the files were compared byte for byte). Every publishing build ran with the configuration cache on (org.gradle.configuration-cache=true in gradle.properties) and reported no configuration cache problems, so they are compatible with it (the small consumer builds that only print dependency reports ran without it); the section "Publishing and the configuration cache" says what that means for secrets. The scratch project pointed its wrapper at a local copy of the distribution archive; the properties file that a real project commits is

distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionSha256Sum=dce76f55f8e251a3a1f130eb120f30b3d271de2b76c9b0729d316b5a1b6dc01f
distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.1-bin.zip
networkTimeout=10000
retries=0
retryBackOffMs=500
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

In the listings, the lines about the configuration cache (Calculating task graph ..., Configuration cache entry stored.) and the lines about the build-logic included build are left out, and the NO-SOURCE tasks are left out of the long task lists. The order of independent tasks in a list changes from run to run. Everything was published to a file:// repository under build/repo of a scratch directory, to a Maven local repository that the run redirected with -Dmaven.repo.local, or to a small stand-in HTTP server on localhost.

Nothing on this page was published to a public repository. io.tidepool is a made-up namespace; no account, token or real key was used. The signing examples use a throwaway key that was generated for the page in a scratch GnuPG home and that expires after one day. What was run, and what was not:

  • Run for real: publishing three projects to a file repository in both DSLs, the generated POM, Gradle Module Metadata and Ivy descriptor, publishToMavenLocal, versionMapping, a relocation POM, an extra artifact, a skipped variant, SNAPSHOT publication, credentials looked up from Gradle properties and environment variables, in-memory signing with verification of the signatures with gpg, and consuming the result from other builds.

  • Run against a stand-in: the HTTP upload. A 26-line Python file server on localhost answers PUT requests and records whether credentials arrived. It is not a repository manager and validates nothing.

  • Not run here: anything that talks to Maven Central or another public host, useGpgCmd() with a gpg-agent, OpenPGP subkeys, and Gradle 8.14 (the 8.x line was not installed; what differs in it comes from the official upgrade notes and is labelled "not run here").

A build that publishes is the last step of every JVM build tool’s life cycle, and the three tools differ in how much they know about it. Ant knows nothing: publishing is a copy or an Ivy or Resolver task that you call yourself (see Ivy: Publishing & Multi-Project Builds and Maven Resolver Ant Tasks). Maven knows one life cycle, and install and deploy are its last two phases (see Install, Deploy & Distribution Management). Gradle models publishing as data: a plugin describes what is published (a publication, made of a component and extra artifacts) and where (a repository), and the plugin generates one task for every combination. This page covers that model, the Maven and Ivy flavours of it, the signing plugin, and the part that Gradle does not do: Maven Central. How the repository layout itself looks, and what the other tools write, is on Repositories & Publishing Across Tools, which also shows a shorter two-module version of the Gradle example.

The publishing model

Three nouns carry the model, and the plugins (maven-publish and ivy-publish) add the types for them.

Noun Meaning

Component

What a project produces, as the plugins define it: components["java"] (the JAR with its metadata, from java or java-library), components["javaPlatform"] (a BOM, from java-platform), components["versionCatalog"] (from version-catalog), components["web"] (from war). A component carries variants: apiElements, runtimeElements, sourcesElements, javadocElements.

Publication

A MavenPublication or IvyPublication in the publishing.publications container. It is built from a component (from(...)) and optionally gets extra artifacts and metadata customisations. One project can have several publications.

Repository

A MavenArtifactRepository or IvyArtifactRepository in publishing.repositories, declared separately from the repositories that the project resolves from. A file URL, an HTTP URL with credentials, or mavenLocal() behind publishToMavenLocal.

flowchart LR subgraph Project C["component: java, javaPlatform"] --> P["publication: maven"] X["extra artifacts"] --> P D["pom { } customisation"] --> P end P --> G1["generatePomFileForMavenPublication"] P --> G2["generateMetadataFileForMavenPublication"] P --> S["signMavenPublication (optional)"] G1 --> U["publishMavenPublicationToLocalFileRepository"] G2 --> U S --> U U --> R[("repository: build/repo, HTTP, Central")] P --> L["publishMavenPublicationToMavenLocal"]

For every publication and repository Gradle registers tasks whose names follow a fixed pattern, and two aggregate tasks that collect them:

Task What it does

generatePomFileFor<Pub>Publication

Writes the POM to build/publications/<pub>/pom-default.xml.

generateMetadataFileFor<Pub>Publication

Writes the Gradle Module Metadata file (module.json) next to it.

publish<Pub>PublicationTo<Repo>Repository

Uploads the artifacts, the POM, the module file and their checksums to the repository named <Repo>. A repository without a name is called Maven (or Ivy), and only one may be unnamed.

publish<Pub>PublicationToMavenLocal

Copies the publication to the Maven local repository.

publish

Aggregate: every publication to every repository. It does not include publishToMavenLocal.

publishToMavenLocal

Aggregate: every publication to the Maven local repository.

publishAllPublicationsTo<Repo>Repository

Aggregate for one repository.

The example build: tidepool

The running example is the tidepool toolkit with three publishable projects: tidepool-model and tidepool-engine (libraries) and tidepool-bom (a bill of materials). The build follows the conventions of the other Gradle pages: repositories are declared once in the settings file, versions live in a version catalog, and the publishing logic lives in a convention plugin in an included build named build-logic (see Convention Plugins & Sharing Build Logic) so that the project scripts only say what each project publishes. The tidepool-cli application of the other pages is left out because an application is not published as a library.

Kotlin DSL: settings.gradle.kts
pluginManagement {
    includeBuild("build-logic")
}

dependencyResolutionManagement {
    repositoriesMode = RepositoriesMode.FAIL_ON_PROJECT_REPOS
    repositories {
        mavenCentral()
    }
}

rootProject.name = "tidepool"

include("tidepool-bom", "tidepool-model", "tidepool-engine")
Groovy DSL: settings.gradle
pluginManagement {
    includeBuild('build-logic')
}

dependencyResolutionManagement {
    repositoriesMode = RepositoriesMode.FAIL_ON_PROJECT_REPOS
    repositories {
        mavenCentral()
    }
}

rootProject.name = 'tidepool'

include 'tidepool-bom', 'tidepool-model', 'tidepool-engine'
gradle.properties (the same for both DSLs)
org.gradle.configuration-cache=true
group=io.tidepool
version=1.0.0
gradle/libs.versions.toml (the same for both DSLs; highlight.js has no TOML grammar, so it is shown as plain text)
[versions]
commons-csv = "1.14.1"

[libraries]
commons-csv = { module = "org.apache.commons:commons-csv", version.ref = "commons-csv" }

The group and version in gradle.properties become the Maven groupId and version of every publication; the project name becomes the artifactId. The build-logic build contains a precompiled script plugin, tidepool.publishing. It applies maven-publish and signing, fills in the POM of every Maven publication of the project with configureEach (so a project cannot forget the licence), declares a file repository named localFile, signs all publications when a key is present, and relaxes the doclint check of the Javadoc tool for the record classes of the example.

Kotlin DSL: build-logic/build.gradle.kts
plugins {
    `kotlin-dsl`
}

repositories {
    gradlePluginPortal()
}
Kotlin DSL: build-logic/src/main/kotlin/tidepool.publishing.gradle.kts
plugins {
    `maven-publish`
    signing
}

publishing {
    publications.withType<MavenPublication>().configureEach {
        pom {
            name = project.name
            description = provider { project.description }
            url = "https://tidepool.example.org"
            licenses {
                license {
                    name = "Apache License, Version 2.0"
                    url = "https://www.apache.org/licenses/LICENSE-2.0.txt"
                }
            }
            developers {
                developer {
                    name = "Tidepool Maintainers"
                    email = "maintainers@tidepool.example.org"
                    organization = "Tidepool"
                    organizationUrl = "https://tidepool.example.org"
                }
            }
            scm {
                connection = "scm:git:https://git.example.org/tidepool.git"
                developerConnection = "scm:git:ssh://git.example.org/tidepool.git"
                url = "https://git.example.org/tidepool"
            }
        }
    }
    repositories {
        maven {
            name = "localFile"
            url = uri(rootProject.layout.buildDirectory.dir("repo"))
        }
    }
}

val signingKey = providers.gradleProperty("signingKey")
val signingPassword = providers.gradleProperty("signingPassword")

signing {
    isRequired = signingKey.isPresent
    useInMemoryPgpKeys(signingKey.orNull, signingPassword.orNull)
    sign(publishing.publications)
}

tasks.withType<Javadoc>().configureEach {
    (options as StandardJavadocDocletOptions).addBooleanOption("Xdoclint:all,-missing", true)
}
Groovy DSL: build-logic/build.gradle
plugins {
    id 'groovy-gradle-plugin'
}

repositories {
    gradlePluginPortal()
}
Groovy DSL: build-logic/src/main/groovy/tidepool.publishing.gradle
plugins {
    id 'maven-publish'
    id 'signing'
}

publishing {
    publications.withType(MavenPublication).configureEach {
        pom {
            name = project.name
            description = provider { project.description }
            url = 'https://tidepool.example.org'
            licenses {
                license {
                    name = 'Apache License, Version 2.0'
                    url = 'https://www.apache.org/licenses/LICENSE-2.0.txt'
                }
            }
            developers {
                developer {
                    name = 'Tidepool Maintainers'
                    email = 'maintainers@tidepool.example.org'
                    organization = 'Tidepool'
                    organizationUrl = 'https://tidepool.example.org'
                }
            }
            scm {
                connection = 'scm:git:https://git.example.org/tidepool.git'
                developerConnection = 'scm:git:ssh://git.example.org/tidepool.git'
                url = 'https://git.example.org/tidepool'
            }
        }
    }
    repositories {
        maven {
            name = 'localFile'
            url = rootProject.layout.buildDirectory.dir('repo')
        }
    }
}

def signingKey = providers.gradleProperty('signingKey')
def signingPassword = providers.gradleProperty('signingPassword')

signing {
    required = signingKey.present
    useInMemoryPgpKeys(signingKey.orNull, signingPassword.orNull)
    sign publishing.publications
}

tasks.withType(Javadoc).configureEach {
    options.addBooleanOption('Xdoclint:all,-missing', true)
}

The project scripts apply the convention plugin and register one publication each. The model first:

Kotlin DSL: tidepool-model/build.gradle.kts
plugins {
    `java-library`
    id("tidepool.publishing")
}

description = "Station, constituent and height value types."

java {
    withSourcesJar()
    withJavadocJar()
}

tasks.withType<JavaCompile>().configureEach {
    options.release = 21
}

publishing {
    publications {
        register<MavenPublication>("maven") {
            from(components["java"])
        }
    }
}
Groovy DSL: tidepool-model/build.gradle
plugins {
    id 'java-library'
    id 'tidepool.publishing'
}

description = 'Station, constituent and height value types.'

java {
    withSourcesJar()
    withJavadocJar()
}

tasks.withType(JavaCompile).configureEach {
    options.release = 21
}

publishing {
    publications {
        maven(MavenPublication) {
            from components.java
        }
    }
}

The engine’s script is the same with another description, and one dependencies block between description and java: the engine exposes the model in its API and keeps Commons CSV to itself (see Java Projects for api and implementation).

Kotlin DSL: tidepool-engine/build.gradle.kts (added block)
dependencies {
    api(project(":tidepool-model"))
    implementation(libs.commons.csv)
}
Groovy DSL: tidepool-engine/build.gradle (added block)
dependencies {
    api project(':tidepool-model')
    implementation libs.commons.csv
}

The BOM is a java-platform project, whose component is javaPlatform:

Kotlin DSL: tidepool-bom/build.gradle.kts
plugins {
    `java-platform`
    id("tidepool.publishing")
}

description = "Bill of materials for the tidepool libraries."

dependencies {
    constraints {
        api(project(":tidepool-model"))
        api(project(":tidepool-engine"))
    }
}

publishing {
    publications {
        register<MavenPublication>("maven") {
            from(components["javaPlatform"])
        }
    }
}
Groovy DSL: tidepool-bom/build.gradle
plugins {
    id 'java-platform'
    id 'tidepool.publishing'
}

description = 'Bill of materials for the tidepool libraries.'

dependencies {
    constraints {
        api project(':tidepool-model')
        api project(':tidepool-engine')
    }
}

publishing {
    publications {
        maven(MavenPublication) {
            from components.javaPlatform
        }
    }
}

The root project applies only the base plugin. With this in place, the publishing tasks of one project are:

./gradlew :tidepool-model:tasks --group publishing
Publishing tasks
----------------
generateMetadataFileForMavenPublication - Generates the Gradle metadata file for publication 'maven'.
generatePomFileForMavenPublication - Generates the Maven POM file for publication 'maven'.
publish - Publishes all publications produced by this project.
publishAllPublicationsToLocalFileRepository - Publishes all Maven publications produced by this project to the localFile repository.
publishMavenPublicationToLocalFileRepository - Publishes Maven publication 'maven' to Maven repository 'localFile'.
publishMavenPublicationToMavenLocal - Publishes Maven publication 'maven' to the local Maven repository.
publishToMavenLocal - Publishes all Maven publications produced by this project to the local Maven cache.

Publishing to a local repository

publish builds everything a publication needs (JAR, sources JAR, Javadoc JAR), generates the metadata and uploads it all:

./gradlew publish
> Task :tidepool-bom:generateMetadataFileForMavenPublication
> Task :tidepool-bom:generatePomFileForMavenPublication
> Task :tidepool-bom:signMavenPublication SKIPPED
> Task :tidepool-model:generatePomFileForMavenPublication
> Task :tidepool-engine:generatePomFileForMavenPublication
> Task :tidepool-bom:publishMavenPublicationToLocalFileRepository
> Task :tidepool-bom:publish
> Task :tidepool-engine:sourcesJar
> Task :tidepool-model:sourcesJar
> Task :tidepool-model:compileJava
> Task :tidepool-model:classes
> Task :tidepool-model:jar
> Task :tidepool-engine:compileJava
> Task :tidepool-engine:classes
> Task :tidepool-engine:jar
> Task :tidepool-model:javadoc
> Task :tidepool-engine:javadoc
> Task :tidepool-model:javadocJar
> Task :tidepool-engine:javadocJar
> Task :tidepool-model:generateMetadataFileForMavenPublication
> Task :tidepool-model:signMavenPublication SKIPPED
> Task :tidepool-engine:generateMetadataFileForMavenPublication
> Task :tidepool-engine:signMavenPublication SKIPPED
> Task :tidepool-model:publishMavenPublicationToLocalFileRepository
> Task :tidepool-model:publish
> Task :tidepool-engine:publishMavenPublicationToLocalFileRepository
> Task :tidepool-engine:publish

BUILD SUCCESSFUL in 4s
28 actionable tasks: 28 executed

The signMavenPublication tasks are SKIPPED because no key was given: the convention plugin made signing optional (isRequired = signingKey.isPresent), and a Sign task without a signatory does nothing when signing is not required. The section "Signing" turns it on. The files for one project in the repository are:

build/repo/io/tidepool/tidepool-model/maven-metadata.xml
build/repo/io/tidepool/tidepool-model/1.0.0/tidepool-model-1.0.0.pom
build/repo/io/tidepool/tidepool-model/1.0.0/tidepool-model-1.0.0.module
build/repo/io/tidepool/tidepool-model/1.0.0/tidepool-model-1.0.0.jar
build/repo/io/tidepool/tidepool-model/1.0.0/tidepool-model-1.0.0-sources.jar
build/repo/io/tidepool/tidepool-model/1.0.0/tidepool-model-1.0.0-javadoc.jar

Gradle also wrote four checksum files for each of the files in that list, .md5, .sha1, .sha256 and .sha512 (the listing leaves them out; there are 15 .sha* files in the version directory, three per file for the SHA family). The maven-metadata.xml that sits one level above the version directory lists the versions of the module:

<?xml version="1.0" encoding="UTF-8"?>
<metadata>
  <groupId>io.tidepool</groupId>
  <artifactId>tidepool-model</artifactId>
  <versioning>
    <latest>1.0.0</latest>
    <release>1.0.0</release>
    <versions>
      <version>1.0.0</version>
    </versions>
    <lastUpdated>20261016192654</lastUpdated>
  </versioning>
</metadata>

The layout is the standard Maven repository layout, which the shared page explains (see Repositories & Publishing Across Tools). The publish* tasks are never UP-TO-DATE: they upload on every run. The tasks that feed them (jar, javadoc, sourcesJar) are up to date as usual. The next sections look at what is in the files.

Publications from components

A publication is built with from(component). What goes into the repository then follows from the component, not from a list of files:

  • components["java"] publishes the jar and, because the build called withSourcesJar() and withJavadocJar(), the two documentation JARs as separate variants. It also publishes the dependencies of the api and runtime configurations to the POM and the module file.

  • components["javaPlatform"] publishes no JAR; the dependency constraints of the platform become the dependencyManagement block of the POM and the constraints of the module file. The generated BOM of the example is:

  <packaging>pom</packaging>
  <name>tidepool-bom</name>
  <description>Bill of materials for the tidepool libraries.</description>
  ...
  <dependencyManagement>
    <dependencies>
      <dependency>
        <groupId>io.tidepool</groupId>
        <artifactId>tidepool-model</artifactId>
        <version>1.0.0</version>
      </dependency>
      <dependency>
        <groupId>io.tidepool</groupId>
        <artifactId>tidepool-engine</artifactId>
        <version>1.0.0</version>
      </dependency>
    </dependencies>
  </dependencyManagement>

A Gradle consumer imports it with platform(...) and leaves the versions out; the consumer here is a separate build that resolves from the file repository (the repository path comes from a project property):

Kotlin DSL: consumer/build.gradle.kts
plugins {
    `java-library`
}

dependencies {
    implementation(platform("io.tidepool:tidepool-bom:1.0.0"))
    implementation("io.tidepool:tidepool-engine")
}
Groovy DSL: consumer/build.gradle
plugins {
    id 'java-library'
}

dependencies {
    implementation platform('io.tidepool:tidepool-bom:1.0.0')
    implementation 'io.tidepool:tidepool-engine'
}
./gradlew dependencies --configuration compileClasspath -PtidepoolRepo=<path to build/repo>
compileClasspath - Compile classpath for source set 'main'.
+--- io.tidepool:tidepool-bom:1.0.0
|    +--- io.tidepool:tidepool-engine:1.0.0 (c)
|    \--- io.tidepool:tidepool-model:1.0.0 (c)
\--- io.tidepool:tidepool-engine -> 1.0.0
     \--- io.tidepool:tidepool-model:1.0.0

(c) - A dependency constraint, not a dependency. The dependency affected by the constraint occurs elsewhere in the tree.

The (c) lines are constraints from the BOM: the version of the engine, which the consumer did not declare, came from there (-> 1.0.0). The platform chapter of this section explains how BOMs and constraints are written and consumed (see Platforms, Constraints & Rich Versions); a Maven consumer imports the same file with the import scope (see Dependency Management & BOMs). The versionCatalog component of the version-catalog plugin publishes a catalog the same way (see Version Catalogs).

Identity: group, name and version

The coordinates of a Maven publication default to Project.getGroup(), Project.getName() and Project.getVersion(). Each can be overridden in the publication (groupId, artifactId, version), for example to publish tidepool-engine under the artifact name tide-engine without renaming the project directory. Gradle validates them early: groupId and artifactId are restricted to letters, digits, _, - and ., while the version may contain any Unicode character except \, / and control characters. An IvyPublication uses organisation, module and revision instead.

Several publications in one project

A project can register more than one publication, and each publication gets its own set of tasks (publishMavenPublicationTo..., publishRelocationPublicationTo...). The convention plugin above uses configureEach on the MavenPublication container so that a publication registered later by the project script is filled in too. The publish task builds all of them. Two things to avoid: calling publishing.publications.all { } or reading a publication’s artifacts early realises every publication immediately, and since Gradle 9.0 a change of the module metadata after an eagerly created publication used the same component fails the build with an error instead of a warning. The examples on this page register publications with register (Kotlin DSL) or the Groovy name(MavenPublication) { } form and change the component first, which is the safe order.

The generated POM

Gradle writes the POM; it is not a file in the source tree. The convention plugin filled the metadata that Maven Central and repository managers expect (name, description, URL, licence, developers, SCM), and the project description came from description = ... in the script. The model’s POM is

  <modelVersion>4.0.0</modelVersion>
  <groupId>io.tidepool</groupId>
  <artifactId>tidepool-model</artifactId>
  <version>1.0.0</version>
  <name>tidepool-model</name>
  <description>Station, constituent and height value types.</description>
  <url>https://tidepool.example.org</url>
  <licenses>
    <license>
      <name>Apache License, Version 2.0</name>
      <url>https://www.apache.org/licenses/LICENSE-2.0.txt</url>
    </license>
  </licenses>
  <developers>
    <developer>
      <name>Tidepool Maintainers</name>
      <email>maintainers@tidepool.example.org</email>
      <organization>Tidepool</organization>
      <organizationUrl>https://tidepool.example.org</organizationUrl>
    </developer>
  </developers>
  <scm>
    <connection>scm:git:https://git.example.org/tidepool.git</connection>
    <developerConnection>scm:git:ssh://git.example.org/tidepool.git</developerConnection>
    <url>https://git.example.org/tidepool</url>
  </scm>

preceded by the project element and the comment that is explained in the section on Gradle Module Metadata. Everything that MavenPom offers can be set in the pom { } block: packaging, inceptionYear, organization, issueManagement, ciManagement, mailingLists, contributors, properties, distributionManagement and so on. The generated engine POM shows how Gradle maps its configurations to Maven scopes:

  <dependencies>
    <dependency>
      <groupId>io.tidepool</groupId>
      <artifactId>tidepool-model</artifactId>
      <version>1.0.0</version>
      <scope>compile</scope>
    </dependency>
    <dependency>
      <groupId>org.apache.commons</groupId>
      <artifactId>commons-csv</artifactId>
      <version>1.14.1</version>
      <scope>runtime</scope>
    </dependency>
  </dependencies>
Gradle configuration Maven scope Consequence for a POM-only consumer

api

compile

On the consumer’s compile and runtime classpath.

implementation, runtimeOnly

runtime

On the consumer’s runtime classpath only; the consumer does not compile against it.

compileOnly

(not published)

Never visible to consumers.

platform and constraints (dependencies { constraints { } })

dependencyManagement

Version management only; no dependency.

Declared versus resolved versions

By default the POM contains the versions that the script declared. If the engine declared the dynamic version 1.14.`, the POM says `1.14. too, which makes the consumer’s build depend on whatever is newest at the time. The versionMapping block publishes the versions that Gradle resolved instead, which is the version that the library was tested with. Both runs below used the engine with implementation("org.apache.commons:commons-csv:1.14.+"). Without versionMapping:

      <artifactId>commons-csv</artifactId>
      <version>1.14.+</version>

and the module file says "requires": "1.14.+". With the mapping added to the publication:

Kotlin DSL: tidepool-engine/build.gradle.kts (inside the publication)
register<MavenPublication>("maven") {
    from(components["java"])
    versionMapping {
        allVariants {
            fromResolutionResult()
        }
    }
}
Groovy DSL: tidepool-engine/build.gradle (inside the publication)
maven(MavenPublication) {
    from components.java
    versionMapping {
        allVariants {
            fromResolutionResult()
        }
    }
}

the published versions became the resolved ones, in the POM (<version>1.14.1</version>) and in the module file ("requires": "1.14.1"). fromResolutionResult() takes the versions from the default classpath of each variant; usage("java-api") { fromResolutionOf("runtimeClasspath") } takes the versions for the declared API dependencies from another configuration. Resolved versions are also what you want after dependency locking (see Dependency Locking, Verification & Caching) and for rich version constraints, whose conversion to the POM is lossy.

Relocation POMs

When a library changes its coordinates, a relocation POM at the old coordinates tells Maven and Gradle where it went. It is a second publication without a component:

Kotlin DSL: tidepool-model/build.gradle.kts (added publication)
register<MavenPublication>("relocation") {
    groupId = "com.example.tide"
    pom {
        distributionManagement {
            relocation {
                groupId = "io.tidepool"
                message = "The groupId has moved to io.tidepool."
            }
        }
    }
}
Groovy DSL: tidepool-model/build.gradle (added publication)
relocation(MavenPublication) {
    groupId = 'com.example.tide'
    pom {
        distributionManagement {
            relocation {
                groupId = 'io.tidepool'
                message = 'The groupId has moved to io.tidepool.'
            }
        }
    }
}

It added the tasks generatePomFileForRelocationPublication and publishRelocationPublicationTo..., and the repository got a second set of coordinates, com.example.tide/tidepool-model/1.0.0/tidepool-model-1.0.0.pom, whose content is

  <modelVersion>4.0.0</modelVersion>
  <groupId>com.example.tide</groupId>
  <artifactId>tidepool-model</artifactId>
  <version>1.0.0</version>
  <packaging>pom</packaging>
  <name>tidepool-model</name>
  <description>Station, constituent and height value types.</description>
  <url>https://tidepool.example.org</url>
  <distributionManagement>
    <relocation>
      <groupId>io.tidepool</groupId>
      <message>The groupId has moved to io.tidepool.</message>
    </relocation>
  </distributionManagement>

Only the changed coordinate needs to be given under relocation. The relocation POM belongs to the next version of the old artifact, is published once, and its publication is then removed from the build; the manual recommends testing it by publishing to a local repository and resolving it from a small Gradle or Maven project first. Maven’s own treatment is on Installing Third-Party JARs & Relocation.

Extra artifacts and variants

There are three ways to put something besides the main JAR into a publication, and the manual ranks them.

Add a variant to the component (recommended): the component is an AdhocComponentWithVariants, so another outgoing configuration can be added with addVariantsFromConfiguration, and an existing variant can be modified or hidden with withVariantsFromConfiguration. This is how the sources and Javadoc JARs are published, and how to publish only one of them. The example below publishes the Javadoc JAR but not the sources JAR, even though the project still builds sourcesJar:

Kotlin DSL: tidepool-model/build.gradle.kts
import org.gradle.api.component.AdhocComponentWithVariants

(components["java"] as AdhocComponentWithVariants)
    .withVariantsFromConfiguration(configurations["sourcesElements"]) { skip() }
Groovy DSL: tidepool-model/build.gradle
components.java.withVariantsFromConfiguration(configurations.sourcesElements) {
    skip()
}

Create a custom component with softwareComponentFactory.adhoc("name") for something that is not a Java library, and publish it with from. Gradle Module Metadata then represents the variants exactly, while the POM needs a mapping of the dependencies to Maven scopes (mapToMavenScope("runtime")). Since Gradle 9.2 the publishing extension exposes the SoftwareComponentFactory directly (publishing.softwareComponentFactory) and accepts providers of configurations, so a variant is realised only when it is published. Not run here.

Attach a file with artifact(...). The file is uploaded, but it is "out of context": neither the POM nor the module file mentions it, and only a dependency with an explicit classifier finds it. The example attaches the station data as a ZIP with the classifier data:

Kotlin DSL: tidepool-model/build.gradle.kts
val dataZip = tasks.register<Zip>("dataZip") {
    archiveClassifier = "data"
    from(rootProject.layout.projectDirectory.dir("data"))
}

publishing {
    publications {
        register<MavenPublication>("maven") {
            from(components["java"])
            artifact(dataZip)
        }
    }
}
Groovy DSL: tidepool-model/build.gradle
def dataZip = tasks.register('dataZip', Zip) {
    archiveClassifier = 'data'
    from rootProject.layout.projectDirectory.dir('data')
}

publishing {
    publications {
        maven(MavenPublication) {
            from components.java
            artifact dataZip
        }
    }
}

With the sources variant hidden, the relocation publication, and the data ZIP, the release repository of that experiment contained

build/repos/releases/com/example/tide/tidepool-model/1.0.0/tidepool-model-1.0.0.pom
build/repos/releases/com/example/tide/tidepool-model/maven-metadata.xml
build/repos/releases/io/tidepool/tidepool-model/1.0.0/tidepool-model-1.0.0-data.zip
build/repos/releases/io/tidepool/tidepool-model/1.0.0/tidepool-model-1.0.0-javadoc.jar
build/repos/releases/io/tidepool/tidepool-model/1.0.0/tidepool-model-1.0.0.jar
build/repos/releases/io/tidepool/tidepool-model/1.0.0/tidepool-model-1.0.0.module
build/repos/releases/io/tidepool/tidepool-model/1.0.0/tidepool-model-1.0.0.pom
build/repos/releases/io/tidepool/tidepool-model/maven-metadata.xml

(checksum files left out). There is no -sources.jar, the -data.zip is there, and neither data.zip nor sourcesElements appears in the module file. Every extra file attached by hand is a file that the dependency resolution engine cannot see, which is why the manual says to prefer variants.

Sources and Javadoc JARs

Maven Central requires a sources JAR and a Javadoc JAR for every library, and most other repositories expect them for IDE navigation. In Gradle both are variants of the java component, switched on in the java { } extension (withSourcesJar() and withJavadocJar(); the tasks sourcesJar and javadocJar and the configurations sourcesElements and javadocElements come with them; see Java Projects). A publication built from(components["java"]) then publishes them, with the classifiers sources and javadoc, and describes them in the module file as variants with org.gradle.category: documentation and org.gradle.docstype of sources or javadoc. Nothing else is needed, unlike Maven where the Source and Javadoc Plugins are bound in a release profile (see Publishing to Maven Central). The Javadoc JAR of the example is about 4 MB.

The Javadoc tool checks the doc comments (doclint) and prints a warning for every record component without an @param tag. The convention plugin turned the missing group off with addBooleanOption("Xdoclint:all,-missing", true), which keeps all other checks. Whether a project documents its public API enough to fail a build is a policy decision.

Repositories and credentials

The publishing { repositories { } } block declares where to upload. It is independent of the repositories that the project uses to download dependencies: dependencyResolutionManagement { repositories { } } and FAIL_ON_PROJECT_REPOS in the settings file concern resolution only.

A release and a snapshot repository

Repositories commonly differ between releases and SNAPSHOTs. A repository URL can be chosen from the version, as the manual shows, or from a project property. The run used the first form, with a second repository named byVersion added to the convention plugin:

Kotlin DSL: build-logic/src/main/kotlin/tidepool.publishing.gradle.kts (added repository)
maven {
    name = "byVersion"
    val releases = rootProject.layout.buildDirectory.dir("repos/releases")
    val snapshots = rootProject.layout.buildDirectory.dir("repos/snapshots")
    url = uri(if (version.toString().endsWith("SNAPSHOT")) snapshots else releases)
}
Groovy DSL: build-logic/src/main/groovy/tidepool.publishing.gradle (added repository)
maven {
    name = 'byVersion'
    def releases = rootProject.layout.buildDirectory.dir('repos/releases')
    def snapshots = rootProject.layout.buildDirectory.dir('repos/snapshots')
    url = version.endsWith('SNAPSHOT') ? snapshots : releases
}
./gradlew :tidepool-model:publishMavenPublicationToByVersionRepository -Pversion=1.0.1-SNAPSHOT

The files of the snapshot went to build/repos/snapshots, and Gradle published them as unique snapshots: the file names carry a timestamp and a build number, and maven-metadata.xml in the version directory maps them back to 1.0.1-SNAPSHOT.

build/repos/snapshots/io/tidepool/tidepool-model/1.0.1-SNAPSHOT/maven-metadata.xml
build/repos/snapshots/io/tidepool/tidepool-model/1.0.1-SNAPSHOT/tidepool-model-1.0.1-20261016.192152-1-data.zip
build/repos/snapshots/io/tidepool/tidepool-model/1.0.1-SNAPSHOT/tidepool-model-1.0.1-20261016.192152-1-javadoc.jar
build/repos/snapshots/io/tidepool/tidepool-model/1.0.1-SNAPSHOT/tidepool-model-1.0.1-20261016.192152-1.jar
build/repos/snapshots/io/tidepool/tidepool-model/1.0.1-SNAPSHOT/tidepool-model-1.0.1-20261016.192152-1.module
build/repos/snapshots/io/tidepool/tidepool-model/1.0.1-SNAPSHOT/tidepool-model-1.0.1-20261016.192152-1.pom
build/repos/snapshots/io/tidepool/tidepool-model/maven-metadata.xml

(Checksum files left out. The Kotlin DSL run is shown; the Groovy run wrote the same files with the timestamp 20261016.192415.) Many repository managers keep SNAPSHOTs and releases apart and refuse a release in a snapshot repository, which is the reason for choosing the URL from the version. Whether Maven Central accepts SNAPSHOTs is a separate question that the Central documentation answers (see the Central documentation).

Credentials from Gradle properties

A remote repository needs credentials, and they must not be in the build script. A repository that declares credentials(PasswordCredentials::class) (credentials(PasswordCredentials) in the Groovy DSL) takes the user name and the password from two Gradle properties whose names are the repository name plus Username and Password. The experiment added a repository named stub to the convention plugin:

Kotlin DSL: build-logic/src/main/kotlin/tidepool.publishing.gradle.kts (added repository)
maven {
    name = "stub"
    url = uri(providers.gradleProperty("stubUrl").orElse("http://localhost:8765/repo"))
    isAllowInsecureProtocol = true
    credentials(PasswordCredentials::class)
}
Groovy DSL: build-logic/src/main/groovy/tidepool.publishing.gradle (added repository)
maven {
    name = 'stub'
    url = providers.gradleProperty('stubUrl').orElse('http://localhost:8765/repo').get()
    allowInsecureProtocol = true
    credentials(PasswordCredentials)
}

isAllowInsecureProtocol (allowInsecureProtocol in the Groovy DSL) is needed only because the stand-in listens on plain HTTP; Gradle refuses http:// repositories otherwise, and a real repository uses HTTPS. Credentials are required only by builds that run a task needing them, and Gradle checks for them before running any task. So the plain publish of the earlier section never asked for the stub credentials, while publishing to the stub repository without them failed before any task ran (the output is the same in both DSLs):

./gradlew :tidepool-model:publishAllPublicationsToStubRepository
FAILURE: Build failed with an exception.

* What went wrong:
Credentials required for this build could not be resolved.
> The following Gradle properties are missing for 'stub' credentials:
    - stubUsername
    - stubPassword

* Try:
> Run with --stacktrace option to get the stack trace.
> Run with --info or --debug option to get more log output.
> Run with --scan to get full insights from a Build Scan (powered by Develocity).
> Get more help at https://help.gradle.org.

BUILD FAILED in 21s

The properties can come from the user’s ~/.gradle/gradle.properties, from -P on the command line, or, in CI, from environment variables with the prefix ORG_GRADLE_PROJECT_ (the same mechanism that carries the signing key below). The run supplied placeholder values through the environment, with the stand-in server running:

ORG_GRADLE_PROJECT_stubUsername=ci-bot ORG_GRADLE_PROJECT_stubPassword=placeholder-secret \
  ./gradlew :tidepool-model:publishAllPublicationsToStubRepository

The server wrote one line per request and logged only the user name, never the password. It received 31 requests: the artifacts and their checksum files, one GET for an existing maven-metadata.xml (the stand-in answered 404), and the new maven-metadata.xml last. The first and the interesting lines are:

PUT /repo/io/tidepool/tidepool-model/1.0.0/tidepool-model-1.0.0.jar -> 201 (user=ci-bot)
PUT /repo/io/tidepool/tidepool-model/1.0.0/tidepool-model-1.0.0.jar.sha1 -> 201 (user=ci-bot)
...
GET /repo/io/tidepool/tidepool-model/maven-metadata.xml -> 404 (auth header sent: True)
PUT /repo/io/tidepool/tidepool-model/maven-metadata.xml -> 201 (user=ci-bot)
...
PUT /repo/io/tidepool/tidepool-model/maven-metadata.xml.sha512 -> 201 (user=ci-bot)

The stand-in never answered with 401, and still every request, including the first, carried the credentials: for this upload they were sent preemptively. (For resolving from a repository Gradle sends credentials by default only after a 401 challenge; a server that answers with another code, for example 404, needs authentication { create<BasicAuthentication>("basic") } to force it.) Other ways to give credentials, all documented: an explicit credentials { username = ...; password = ... } block with values from providers.environmentVariable("NAME") (a provider, so the value is read lazily and tracked by the configuration cache), or token and header-based schemes (HttpHeaderCredentials, AwsCredentials) for repositories that use them. Never put the values in build.gradle.kts or in a gradle.properties that is committed.

Publishing to the Maven local repository

publishToMavenLocal copies the publications to the Maven local repository (by default ~/.m2/repository) so that a Maven build on the same machine can use them. It needs no mavenLocal() in the publishing { repositories { } } block, and it is the only way here to "install" in the Maven sense. The run redirected the target to a scratch directory with the standard Maven property maven.repo.local, which Gradle honours, so that the real ~/.m2 was not touched:

./gradlew publishToMavenLocal -Dmaven.repo.local=<scratch directory>/m2
> Task :tidepool-bom:publishMavenPublicationToMavenLocal
> Task :tidepool-bom:publishToMavenLocal
> Task :tidepool-model:publishMavenPublicationToMavenLocal
> Task :tidepool-model:publishToMavenLocal
> Task :tidepool-engine:publishMavenPublicationToMavenLocal
> Task :tidepool-engine:publishToMavenLocal

BUILD SUCCESSFUL in 1s

The scratch repository then contained the JARs, the POMs and the module files, and a maven-metadata-local.xml per artifact, but no checksum files (the count of .sha* and .md5 files was zero). If the checksums matter for the test, publish to a file:// repository instead. Also: treat mavenLocal() as a last resort on the resolving side; its pitfalls are listed in Repositories & Publishing Across Tools.

Gradle Module Metadata

Besides the POM, the repository got a file tidepool-model-1.0.0.module. That is Gradle Module Metadata (GMM), a JSON file that describes the variants of a module: for each one its attributes (usage, category, JVM version, docstype), its dependencies and its files with their sizes and checksums. Gradle publishes it by default since Gradle 6.0, always alongside the POM or the Ivy descriptor, never instead. The file begins

{
  "formatVersion": "1.1",
  "component": {
    "group": "io.tidepool",
    "module": "tidepool-model",
    "version": "1.0.0",
    "attributes": {
      "org.gradle.status": "release"
    }
  },
  "createdBy": {
    "gradle": {
      "version": "9.8.1"
    }
  },
  "variants": [

and the model has four variants: apiElements, runtimeElements, sourcesElements and javadocElements. The variants for compiling and running carry the attributes org.gradle.usage (java-api or java-runtime), org.gradle.libraryelements: jar, org.gradle.category: library and org.gradle.jvm.version: 21. The engine’s runtimeElements variant lists Commons CSV and the model under dependencies, its apiElements variant only the model (the same split as the Maven scopes, but expressed per variant). Gradle’s dependency resolution reads this file instead of the POM when it finds it (see Variant-Aware Resolution & Module Metadata for the consumer side).

The POM carries a marker comment, do_not_remove: published-with-gradle-metadata, in front of the modelVersion element: it tells Gradle that a richer description exists. It is an optimisation (Gradle reads the POM first, and fetches the module file only if the marker is there), not a statement for other tools. Maven reads the POM and ignores the module file; for it, Gradle maps what it can: constraints become dependencyManagement, rich version constraints publish their requires version, capabilities are not published at all, and feature variants become optional dependencies. The full table is in the manual; a build that uses something that only GMM can express prints a warning at publication time, which suppressPomMetadataWarningsFor("runtimeElements") on the publication silences.

Why consumers prefer the module file

The attributes are not decoration. The model and the engine were compiled with options.release = 21, so GMM says that they are only compatible with Java 21 or newer. A consumer that targets Java 17 and resolves from the repository with the module file:

Kotlin DSL: consumer/build.gradle.kts
plugins {
    `java-library`
}

java {
    targetCompatibility = JavaVersion.VERSION_17
}

dependencies {
    implementation("io.tidepool:tidepool-engine:1.0.0")
}
Groovy DSL: consumer/build.gradle
plugins {
    id 'java-library'
}

java {
    targetCompatibility = JavaVersion.VERSION_17
}

dependencies {
    implementation 'io.tidepool:tidepool-engine:1.0.0'
}
./gradlew dependencyInsight --dependency tidepool-engine --configuration compileClasspath -PtidepoolRepo=<path to build/repo>
io.tidepool:tidepool-engine:1.0.0 FAILED
   Failures:
      - Could not resolve io.tidepool:tidepool-engine:1.0.0.
          - Dependency resolution is looking for a library compatible with JVM runtime version 17, but 'io.tidepool:tidepool-engine:1.0.0' is only compatible with JVM runtime version 21 or newer.
          - Change the dependency on 'io.tidepool:tidepool-engine:1.0.0' to an earlier version that supports JVM runtime version 17.

The build fails at resolution time, with a message that names the cause. The same artifacts published without the module file (the GenerateModuleMetadata tasks disabled, see below) resolve without complaint for the same consumer, because a POM has no way to say "Java 21":

compileClasspath - Compile classpath for source set 'main'.
\--- io.tidepool:tidepool-engine:1.0.0
     \--- io.tidepool:tidepool-model:1.0.0

and the first symptom would be an UnsupportedClassVersionError when the consumer ran. Variants, attributes and capabilities are what make that possible; they are not repository features, they are in the metadata.

Disabling, validating and varying the module file

  • Disabling it: some repositories reject the unknown file, or the build uses Maven-only constructs. Turn off the generating tasks in the build (or in the convention plugin): tasks.withType<GenerateModuleMetadata>().configureEach { enabled = false } in the Kotlin DSL and tasks.withType(GenerateModuleMetadata).configureEach { enabled = false } in the Groovy DSL. The run showed generateMetadataFileForMavenPublication SKIPPED for all three projects, the repository then had only .pom, .jar and their checksums per artifact, and the POM no longer had the marker comment. It also changes what consumers see, as above.

  • Validation: Gradle validates the file before publishing it (variant names unique, every variant has an attribute, no two variants with the same attributes and capabilities, at least one dependency version). If a rule trips on something you did on purpose, the error names the rule, and tasks.withType<GenerateModuleMetadata>().configureEach { suppressedValidationErrors.add("enforced-platform") } (Groovy DSL: suppressedValidationErrors.add('enforced-platform')) suppresses it. enforced-platform is the example name from the manual, and the line was run in both DSLs to confirm that it is accepted; no validation error was triggered.

  • Reproducibility: the generating task is never UP-TO-DATE, but its output is the same for the same inputs; the same JARs and the same build produced the same .module and .pom files in the Kotlin and Groovy runs. withBuildIdentifier() on a publication adds a unique build identifier to the file if each build must differ.

  • Consumer side: if every module you depend on is published with GMM you can skip the POM fetch with metadataSources { gradleMetadata() } on the repository (see Repositories & Content Filtering). Not run here.

Publishing to Ivy repositories

The ivy-publish plugin is the Ivy twin of maven-publish: the same publishing extension, with IvyPublication (organisation, module, revision, a descriptor { } block) and ivy { } repositories, and the task names generateDescriptorFor<Pub>Publication and publish<Pub>PublicationTo<Repo>Repository. Use it for an Ivy-based consumer (see Ivy: Publishing & Multi-Project Builds); everything else on this page applies unchanged. The experiment used a build with only ivy-publish in the model and the engine:

Kotlin DSL: tidepool-model/build.gradle.kts
plugins {
    `java-library`
    `ivy-publish`
}

description = "Station, constituent and height value types."

java {
    withSourcesJar()
}

publishing {
    publications {
        register<IvyPublication>("ivy") {
            from(components["java"])
            descriptor {
                status = "release"
                license {
                    name = "Apache License, Version 2.0"
                    url = "https://www.apache.org/licenses/LICENSE-2.0.txt"
                }
                author {
                    name = "Tidepool Maintainers"
                    url = "https://tidepool.example.org"
                }
                description {
                    text = project.description
                    homepage = "https://tidepool.example.org"
                }
            }
        }
    }
    repositories {
        ivy {
            name = "localFile"
            url = uri(rootProject.layout.buildDirectory.dir("ivy-repo"))
        }
    }
}
Groovy DSL: tidepool-model/build.gradle
plugins {
    id 'java-library'
    id 'ivy-publish'
}

description = 'Station, constituent and height value types.'

java {
    withSourcesJar()
}

publishing {
    publications {
        ivy(IvyPublication) {
            from components.java
            descriptor {
                status = 'release'
                license {
                    name = 'Apache License, Version 2.0'
                    url = 'https://www.apache.org/licenses/LICENSE-2.0.txt'
                }
                author {
                    name = 'Tidepool Maintainers'
                    url = 'https://tidepool.example.org'
                }
                description {
                    text = project.description
                    homepage = 'https://tidepool.example.org'
                }
            }
        }
    }
    repositories {
        ivy {
            name = 'localFile'
            url = rootProject.layout.buildDirectory.dir('ivy-repo')
        }
    }
}

./gradlew publish ran generateDescriptorFileForIvyPublication, generateMetadataFileForIvyPublication and publishIvyPublicationToLocalFileRepository, and wrote the Ivy layout [organisation]/[module]/[revision]/: ivy-1.0.0.xml, the JARs, the module file, and .sha1, .sha256 and .sha512 checksums (no .md5, unlike the Maven repository). The model’s descriptor is

<?xml version="1.0" encoding="UTF-8"?>
<ivy-module version="2.0" xmlns:m="http://ant.apache.org/ivy/maven">
  <!-- This module was also published with a richer model, Gradle metadata,  -->
  <!-- which should be used instead. Do not delete the following line which  -->
  <!-- is to indicate to Gradle or any Gradle module metadata file consumer  -->
  <!-- that they should prefer consuming it instead. -->
  <!-- do_not_remove: published-with-gradle-metadata -->
  <info organisation="io.tidepool" module="tidepool-model" revision="1.0.0" status="release" publication="20261016212123">
    <license name="Apache License, Version 2.0" url="https://www.apache.org/licenses/LICENSE-2.0.txt"/>
    <ivyauthor name="Tidepool Maintainers" url="https://tidepool.example.org"/>
    <description homepage="https://tidepool.example.org">Station, constituent and height value types.</description>
  </info>
  <configurations>
    <conf name="compile" visibility="public"/>
    <conf name="default" visibility="public" extends="runtime"/>
    <conf name="runtime" visibility="public"/>
    <conf name="sourcesElements" visibility="public"/>
  </configurations>
  <publications>
    <artifact name="tidepool-model" type="jar" ext="jar" conf="compile,runtime"/>
    <artifact name="tidepool-model" type="jar" ext="jar" conf="sourcesElements" m:classifier="sources"/>
  </publications>
  <dependencies/>
</ivy-module>

and the engine’s dependencies show the same mapping as in the POM, now as Ivy configuration mappings:

  <dependencies>
    <dependency org="io.tidepool" name="tidepool-model" rev="1.0.0" conf="compile-&gt;default"/>
    <dependency org="org.apache.commons" name="commons-csv" rev="1.14.1" conf="runtime-&gt;default"/>
    <dependency org="io.tidepool" name="tidepool-model" rev="1.0.0" conf="runtime-&gt;default"/>
  </dependencies>

In an Ivy descriptor the Gradle variants collapse into the three Ivy configurations compile, runtime and default; feature variants lose their dependencies, which the module file keeps. The publication attribute is a timestamp and differs on every run.

Signing

Maven Central and most corporate repositories require an OpenPGP signature (.asc) next to every artifact. The signing plugin creates Sign tasks and wires them into the publication; the convention plugin of the example already did that with sign(publishing.publications), which signs every publication, including the ones added later, and creates the tasks signMavenPublication (one per project). A publication signed this way publishes its signatures with the artifacts: the .asc files are uploaded as additional artifacts of the same publication.

Where the key comes from

The plugin supports several ways to give it a key (see the manual for the key-ring file properties signing.keyId, signing.password and signing.secretKeyRingFile, and for the GnuPG command-line signatory with useGpgCmd(), which can use a gpg-agent; those were not run here). The one that fits CI is the in-memory ASCII-armoured key: the key and its passphrase are ordinary Gradle properties, so they can be set from environment variables named ORG_GRADLE_PROJECT_signingKey and ORG_GRADLE_PROJECT_signingPassword, and useInMemoryPgpKeys(key, password) reads them. For a key with subkeys there is a three-argument form that takes the key id as well. The convention plugin reads the properties through providers.gradleProperty, so that Gradle knows they are inputs of the build, and makes signing mandatory only when the key is there: with no key the Sign task skips itself (as in the first publish run), and a developer can build and publish snapshots without secrets.

The run used a throwaway key. The key was generated in a scratch GnuPG home with a one-day lifetime (3072-bit RSA, user id Tidepool Throwaway <throwaway@tidepool.example.org>), exported with gpg --armor --export-secret-keys into a file in the scratch directory, and passed in through the environment:

export ORG_GRADLE_PROJECT_signingKey="$(cat throwaway-secret.asc)"
export ORG_GRADLE_PROJECT_signingPassword=<passphrase of the throwaway key>
./gradlew clean publish
> Task :tidepool-bom:signMavenPublication
> Task :tidepool-engine:signMavenPublication
> Task :tidepool-model:signMavenPublication
...
BUILD SUCCESSFUL in 4s

(Only the sign lines are shown. The first line of the run was Calculating task graph as configuration cache cannot be reused because Gradle property 'signingKey' has changed.: a changed secret invalidates the configuration cache entry, which is correct, since the key is part of the configuration.) Twelve signature files appeared in the repository, one for every artifact that is not a checksum or metadata file: for each of the model and the engine, .pom.asc, .module.asc, .jar.asc, -sources.jar.asc and -javadoc.jar.asc, and for the BOM .pom.asc and .module.asc. Verifying one with the public key of the scratch home (the output of gpg was produced with LC_ALL=C to get English messages):

gpg --verify tidepool-model-1.0.0.pom.asc tidepool-model-1.0.0.pom
gpg: Signature made Fri Oct 16 21:18:40 2026 CEST
gpg:                using RSA key 188C968DDAB26AE8
gpg: Good signature from "Tidepool Throwaway <throwaway@tidepool.example.org>" [ultimate]

The same command verified the .jar.asc; the Groovy run produced twelve signature files too (one was verified). The key id and the date are those of the throwaway key; the private key was never written to the page.

Details from the manual and the upgrade notes:

  • Conditional signing: isRequired (Kotlin DSL) or required (Groovy DSL) accepts a value or a closure, so signing { setRequired({ isReleaseVersion && gradle.taskGraph.hasTask("publish") }) } signs only a release build that publishes. If signing is not required but credentials are configured, the artifacts are signed anyway.

  • Signatures and checksums: Gradle does not write checksum files for the .asc files. The run confirmed it (no .asc.sha1 or other). That behaviour is new in Gradle 9.7 (see "What changed between Gradle 8.14 and 9.x"); before it Gradle uploaded checksum files for signatures too.

  • Signature version: since Gradle 9.0.0 the plugin produces OpenPGP signatures that match the version of the key, so a version 6 key signs with version 6 (RFC 9580); Gradle 8.x always produced version 4 signatures. Not run here.

  • Signing other things: sign(configurations.runtimeElements.get()) signs the outputs of a configuration (the task is signRuntimeElements), and sign(tasks["stuffZip"]) signs an archive task (signStuffZip).

Publishing to Maven Central

Maven Central has accepted uploads through only one door since 2025-06-30: the Central Publisher Portal. The old OSSRH service and its legacy deployment protocol, which the Maven Deploy Plugin and the Gradle maven-publish repositories used, were shut down on that date. What this means for a Gradle build:

  • There is no official Gradle plugin for the Portal. The Central documentation says so on its Gradle page, and says that Gradle support is on its roadmap. A maven { url = ... } repository with credentials is therefore not enough to reach Central. That is why the example does not claim to publish there.

  • The options (all outside Gradle’s own distribution and, per the Central documentation, community-supported): the JReleaser Gradle plugin, which supports publishing via the Portal; the Gradle Maven Publish Plugin by Vanniktech (com.vanniktech.maven.publish), whose documentation describes a publishToMavenCentral and a publishAndReleaseToMavenCentral task, GPG signing from in-memory keys, and which is also the plugin used on Building and Publishing Android Libraries to Maven Central; and the other community plugins that the Central page lists. Check the maintenance state of whichever you pick before you rely on it.

  • The OSSRH Staging API compatibility service: Central provides a service that accepts the old "Maven-API-like" protocol, so that a plugin written for OSSRH can still deploy. Gradle’s built-in maven-publish is on the list of plugins that Central tested with it, with a caveat: a plain maven { } repository that points to the compatibility endpoint (ossrh-staging-api.central.sonatype.com) uploads the files, but the deployment only becomes visible in the Portal after one more request to a manual endpoint (a POST to /manual/upload/defaultRepository/<namespace> from the same IP address as the upload, at the end of the CI job). The credentials are a Portal user token; an old OSSRH token gets 401. See the Central page for the exact steps; they were not run here.

  • Doing it by hand: the Portal accepts a ZIP bundle of the repository tree, with checksums and signatures, through the Publisher API (or the web UI). The repository that publish writes to build/repo has exactly that layout, so zipping it (without the maven-metadata.xml files) yields a bundle; Repositories & Publishing Across Tools shows how to build one from any tool’s repository, and Publishing to Maven Central covers what Central requires and the flow with Maven.

What Central requires does not depend on the tool: a verified namespace, the full POM metadata (name, description, URL, licence, developers, SCM), the sources and Javadoc JARs, checksums and a signature for every file. The example build fills all of it, except the verified namespace and a real key. A complete walk through the Portal from the Gradle side, with a real library, is on Building and Publishing Android Libraries to Maven Central. Nothing was uploaded to Central or tested against it for this page; this section is "not run here" in full.

Publishing and the configuration cache

The publishing tasks are compatible with the configuration cache: all runs on this page had it enabled, none reported a problem, and the cache was reused across runs:

Reusing configuration cache.
BUILD SUCCESSFUL in 691ms
19 actionable tasks: 6 executed, 13 up-to-date
Configuration cache entry reused.

(The six executed tasks are the three generateMetadataFileForMavenPublication tasks and the three publishMavenPublicationToLocalFileRepository tasks, which are never up to date; the sign tasks were skipped.) Two things about secrets and the cache follow from what the earlier sections showed. First, the signing key and the credentials are configuration inputs: the cache entry records them, and a different key invalidates it (the message about signingKey above). Second, anything held in the state of a task goes into the cache entry as well; Gradle encrypts the cache entries on disk to reduce the risk of exposure, but whoever can read the cache directory and the encryption key can read the entries. On CI, restore or save the Gradle User Home carefully, and do not share a configuration cache between jobs of different trust levels. The configuration cache chapters explain the details and how to configure the encryption key (see Configuration Cache & Isolated Projects).

End to end: tidepool to build/repo and back

Putting the sections together, the sequence that a release job runs is: build, test, publish to a staging location, verify what was written, and only then promote it. For tidepool the staging location is build/repo:

./gradlew clean build publish
find build/repo -name '*.asc' | wc -l
gpg --verify build/repo/io/tidepool/tidepool-engine/1.0.0/tidepool-engine-1.0.0.pom.asc \
  build/repo/io/tidepool/tidepool-engine/1.0.0/tidepool-engine-1.0.0.pom

with the key properties exported as before. The run printed BUILD SUCCESSFUL (35 actionable tasks), the repository held twelve .asc files, and the POM signature verified. Per module the repository holds the POM, the module file, the main JAR, the sources and Javadoc JARs, their checksums and their signatures, and a consumer, whether Gradle (shown above, with the module file and with the BOM), Maven (see Dependency Management & BOMs) or Ant with the Resolver tasks (see Maven Resolver Ant Tasks), can resolve from it. The shared page runs the same publication from all three tools and the three consumers against it; the signed version of the file listing above is the one that goes to Central or to a repository manager. The supply-chain side of the same files, signature verification by consumers and reproducible builds, is on Supply Chain Security.

What changed between Gradle 8.14 and 9.x

Gradle 8.14.6 (the last 8.x release) was not installed here; the table is from the official release notes and upgrade notes and is "not run here" for the 8.14 side. The 9.8.1 side was run where the earlier sections say so.

Release Change for publishing

6.0

Gradle Module Metadata is published by default, next to the POM.

7.0

The maven plugin, uploadArchives and the compile/runtime configurations were removed; maven-publish and ivy-publish are the only publishing plugins.

9.0

The signing plugin produces OpenPGP signatures that match the version of the key (8.x: always version 4). A change of the module metadata after an eagerly populated publication fails with an error (8.x: a warning).

9.2

PublishingExtension.getSoftwareComponentFactory() and provider-based methods for adding variants to custom components.

9.3

Publishing a project that depends on a project without a publication is deprecated; in Gradle 10 it fails the build. Before, Gradle silently used the group, name and version of the target project.

9.7

No checksum files for signature files (.asc, .sig) when publishing to Maven or Ivy repositories. The run on 9.8.1 wrote none.

The two forms that matter when upgrading are the 9.3 deprecation (a project dependency such as api(project(":other")) needs :other to apply maven-publish and register a publication, otherwise the dependency in the published metadata is a guess) and the 9.7 checksum change (a repository that insisted on .asc.sha1 files will not get them from 9.7 on). The upgrade page of this section collects the rest (see Upgrading Gradle).

The same job in Ant and Maven

Step Gradle Maven Ant

Generate the POM

generatePomFileFor<Pub>Publication, from the component and the pom { } block

The POM is the source file; mvn deploy publishes it

Written by hand, or generated with the Resolver pom or Ivy makepom tasks

Sources and Javadoc JARs

withSourcesJar() and withJavadocJar() in java { }

Source and Javadoc Plugins bound to package, usually in a release profile

jar tasks over source and Javadoc directories

Sign

signing plugin, sign(publishing.publications)

Maven GPG Plugin bound to verify

gpg through exec; the Resolver tasks do not produce signatures

Upload

publish<Pub>PublicationTo<Repo>Repository

mvn deploy with distributionManagement and credentials in settings.xml

Resolver deploy or Ivy publish

Install locally

publishToMavenLocal

mvn install

Resolver install

Central Portal

No official plugin; community plugins, the Staging API or a bundle

central-publishing-maven-plugin

A bundle by hand

The long form of each column is on the pages for the tools: Install, Deploy & Distribution Management, Publishing to Maven Central, Ivy: Publishing & Multi-Project Builds and Maven Resolver Ant Tasks; the comparison of the three tools' repositories is Repositories & Publishing Across Tools.

Where the book is outdated

Gradle in Action (Gradle 1.7), in its chapter on artifact assembly and publishing, describes a stage of Gradle that no longer exists:

  • The maven plugin and uploadArchives. The book publishes with apply plugin: 'maven' and an uploadArchives task that uploads the archives configuration through a Maven deployer. Both were removed in Gradle 7.0; maven-publish (or ivy-publish) with a publishing { } block is the only way.

  • maven-publish and ivy-publish as incubating. They were incubating then; they are stable and the standard.

  • Publishing "the archives". A list of files in a configuration is replaced by a publication built from a component, which carries variants, metadata and dependencies. A hand-picked file is the exception.

  • Uploading to Sonatype OSSRH. The service that open source libraries used to reach Central was shut down on 2025-06-30; see the Central section above.

Common mistakes

  • Publishing without from(components[...]). A publication without a component or artifacts publishes only a POM. The relocation POM above is the one case where that is intended.

  • Putting credentials in the script or in a committed gradle.properties. Use credentials(PasswordCredentials) with properties from the user’s Gradle home or the environment.

  • Believing publish includes publishToMavenLocal. It does not.

  • Hand-attached artifacts. A file added with artifact(...) is invisible to dependency resolution; add a variant.

  • Forgetting that a POM cannot carry attributes. A POM-only repository lets a Java 17 build resolve a Java 21 library. Publish the module file unless a repository rejects it.

  • Unconditional signing. Making signing mandatory means that every developer build needs the key. Require it for releases only.

  • Declared dynamic versions in published metadata. Use versionMapping.

  • Treating Central as an ordinary Maven repository. Since 2025-06-30 it is not.

  • Publishing a project that depends on an unpublished project. Deprecated since 9.3, an error in Gradle 10.

Where to go next