Gradle Publishing, Maven Publish, Ivy Publish, Signing, Metadata, and Repository Promotion: Guided Hands-On Workflow and Core Operations
Publish one small Java library with Gradle core publishing plugins to disposable Maven and Ivy repositories, inspect every generated artifact and descriptor, consume the Maven publication from clean Gradle and Maven clients, and optionally add a disposable OpenPGP signature.
Learning objectives
- Create a wrapper-based Java library with Maven and Ivy publications using only Gradle core plugins.
- Generate and inspect POM, Ivy descriptor, Gradle Module Metadata, sources/Javadoc JARs, checksums, and publication tasks.
- Publish to disposable file repositories and keep Maven Local optional/diagnostic.
- Consume the same Maven coordinate from clean Gradle and Maven clients.
- Optionally sign the Maven publication with a disposable OpenPGP key without storing real secrets.
1. Lab boundary and preflight
Use a disposable copy of a project that already contains the trusted Gradle 9.7.1 Wrapper from Chapter 15. The lab creates only project-relative files and an isolated Gradle User Home. Do not point any repository URL at an employer or public repository.
mkdir -p ch26-publishing-lab
cd ch26-publishing-lab
# Copy in the already-verified gradlew, gradlew.bat and gradle/wrapper/ files
# from your trusted course fixture before continuing.
test -f ./gradlew
test -f ./gradle/wrapper/gradle-wrapper.properties
export GRADLE_USER_HOME="$PWD/.lab-gradle-home"
java -version
./gradlew --version
./gradlew --offline help || ./gradlew help
The final command deliberately prefers offline execution. If the pinned distribution or required dependencies are not cached, the fallback allows the trusted Wrapper to fetch what it needs. Nothing in the mandatory publication path requires a hosted repository account.
2. Create the publisher model
Create publisher/settings.gradle.kts,
publisher/build.gradle.kts, and one Java source file.
The Java component supplies the main JAR plus the source/Javadoc
variants. The two publications serialize that same component into
Maven and Ivy repository models.
rootProject.name = "ledger-api"
plugins {
`java-library`
`maven-publish`
`ivy-publish`
signing
}
group = "dev.academy.publish"
version = "1.0.0"
description = "Small publication fixture for DevOps Academy Chapter 26"
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
withSourcesJar()
withJavadocJar()
}
tasks.withType<JavaCompile>().configureEach {
options.release.set(17)
}
publishing {
publications {
create<MavenPublication>("mavenJava") {
from(components["java"])
pom {
name = "Academy Ledger API"
description = project.description
url = "https://example.invalid/academy-ledger-api"
licenses {
license {
name = "Training-only fixture"
url = "https://example.invalid/license"
}
}
}
}
create<IvyPublication>("ivyJava") {
from(components["java"])
}
}
repositories {
maven {
name = "stagingMaven"
url = uri(layout.buildDirectory.dir("repositories/staging-maven"))
}
ivy {
name = "stagingIvy"
url = uri(layout.buildDirectory.dir("repositories/staging-ivy"))
}
}
}
// Optional lab-only signing lane. It is inactive unless -PlabSign=true.
signing {
if (providers.gradleProperty("labSign").orNull == "true") {
useGpgCmd()
sign(publishing.publications["mavenJava"])
}
}
package dev.academy.publish;
/** Small deterministic library used to prove publication identity. */
public final class LedgerApi {
private LedgerApi() {}
public static String receipt(String account, long cents) {
if (account == null || account.isBlank()) {
throw new IllegalArgumentException("account must not be blank");
}
return account + ":" + cents;
}
}
3. Inspect before any write-capable publish task
./gradlew -p publisher projects
./gradlew -p publisher outgoingVariants
./gradlew -p publisher tasks --group publishing
./gradlew -p publisher properties | grep -E '^(group|version|name):' || true
Expect publishing tasks whose names combine publication and
repository names, including
publishMavenJavaPublicationToStagingMavenRepository and
publishIvyJavaPublicationToStagingIvyRepository. The
aggregate publish task targets declared publishing
repositories; publishToMavenLocal is separate.
4. Generate metadata without publishing
Generation tasks let you inspect the consumer contract before
uploading it anywhere. For the Maven publication, the POM is
generated under
build/publications/mavenJava/pom-default.xml. Ivy
generation writes build/publications/ivyJava/ivy.xml.
Gradle also generates module metadata for the publications.
./gradlew -p publisher \
generatePomFileForMavenJavaPublication \
generateMetadataFileForMavenJavaPublication \
generateDescriptorFileForIvyJavaPublication \
generateMetadataFileForIvyJavaPublication
find publisher/build/publications -type f -print
cat publisher/build/publications/mavenJava/pom-default.xml
cat publisher/build/publications/ivyJava/ivy.xml
find publisher/build/publications \( -name 'module.json' -o -name '*.module' \) -print
The POM expresses Maven coordinates and Maven-compatible dependency metadata. The Ivy descriptor expresses Ivy identity/configuration metadata. GMM records the Gradle variants/attributes/capabilities that Maven/Ivy descriptors cannot always express directly. Inspect publication warnings rather than suppressing them by reflex.
5. Publish to disposable Maven and Ivy file repositories
Now mutate only lab-owned repository directories. A successful task
should produce the Java binary, -sources.jar,
-javadoc.jar, native descriptor, Gradle Module
Metadata, and repository checksums.
./gradlew -p publisher \
publishMavenJavaPublicationToStagingMavenRepository \
publishIvyJavaPublicationToStagingIvyRepository
find publisher/build/repositories/staging-maven -type f -print | sort
find publisher/build/repositories/staging-ivy -type f -print | sort
jar tf publisher/build/libs/ledger-api-1.0.0.jar
jar tf publisher/build/libs/ledger-api-1.0.0-sources.jar
jar tf publisher/build/libs/ledger-api-1.0.0-javadoc.jar | head -40
The Maven repository path will reflect the group path
dev/academy/publish/ledger-api/1.0.0. Ivy uses its
configured Ivy layout. Avoid assuming the two directory layouts are
interchangeable just because both contain the same binary JAR.
6. Capture artifact identity before promotion
Record the binary JAR digest from both the build output and staged repository. They should match because publishing transfers the artifact rather than recompiling it.
sha256sum publisher/build/libs/ledger-api-1.0.0.jar
sha256sum publisher/build/repositories/staging-maven/dev/academy/publish/ledger-api/1.0.0/ledger-api-1.0.0.jar
# Save the staged release evidence for the checkpoint.
sha256sum publisher/build/repositories/staging-maven/dev/academy/publish/ledger-api/1.0.0/* \
> publisher/build/staging-maven.sha256
Get-FileHash publisher\build\libs\ledger-api-1.0.0.jar -Algorithm SHA256
Get-FileHash publisher\build\repositories\staging-maven\dev\academy\publish\ledger-api\1.0.0\ledger-api-1.0.0.jar -Algorithm SHA256
7. Maven Local is diagnostic, not the main lab
publishToMavenLocal writes to the Maven Local
repository, typically ~/.m2/repository. It is useful
when debugging integration with Maven tooling, but it mutates normal
user state and Maven Local is intentionally mutable. The mandatory
lab does not run it.
# Optional diagnostic only — not part of the mandatory lab:
./gradlew -p publisher publishToMavenLocal
If you choose to run that optional command on your own machine,
record exactly which coordinate it wrote. Do not add
mavenLocal() to production dependency resolution merely
because this diagnostic is convenient.
8. Clean Gradle consumer
Create a separate consumer project. It does not use a project dependency; it resolves the published coordinate from the staged Maven-format repository. This proves the repository contract independently of the publisher project graph.
rootProject.name = "gradle-consumer"
plugins {
application
}
repositories {
maven {
val publicationRepo = providers.gradleProperty("publicationRepo")
url = uri(publicationRepo.get())
}
}
dependencies {
implementation("dev.academy.publish:ledger-api:1.0.0")
}
application {
mainClass = "dev.academy.consumer.Main"
}
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}
tasks.withType<JavaCompile>().configureEach {
options.release.set(17)
}
package dev.academy.consumer;
import dev.academy.publish.LedgerApi;
public final class Main {
public static void main(String[] args) {
System.out.println(LedgerApi.receipt("acct-7", 4250));
}
}
./gradlew -p gradle-consumer \
-PpublicationRepo="$PWD/publisher/build/repositories/staging-maven" \
dependencies --configuration runtimeClasspath
./gradlew -p gradle-consumer \
-PpublicationRepo="$PWD/publisher/build/repositories/staging-maven" \
run
Expected application output contains acct-7:4250. In
the dependency report, the external module identity must be
dev.academy.publish:ledger-api:1.0.0, not
project :publisher.
9. Clean Maven consumer
Create a small Maven project beside publisher. Its
repository URL points at the staged Maven-format file repository,
and its dependency uses the same GAV. Maven ignores Gradle Module
Metadata and uses the POM.
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>dev.academy.consumer</groupId>
<artifactId>maven-consumer</artifactId>
<version>1.0.0</version>
<properties>
<maven.compiler.release>17</maven.compiler.release>
</properties>
<repositories>
<repository>
<id>academy-release</id>
<url>file://${project.basedir}/../publisher/build/repositories/staging-maven</url>
</repository>
</repositories>
<dependencies>
<dependency>
<groupId>dev.academy.publish</groupId>
<artifactId>ledger-api</artifactId>
<version>1.0.0</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.15.0</version>
</plugin>
</plugins>
</build>
</project>
package dev.academy.consumer;
import dev.academy.publish.LedgerApi;
public final class Main {
public static void main(String[] args) {
System.out.println(LedgerApi.receipt("acct-7", 4250));
}
}
mvn -f maven-consumer/pom.xml \
-Dmaven.repo.local="$PWD/.lab-m2" \
-DskipTests compile
# The compiled consumer proves Maven resolved the published JAR from the file repo.
find maven-consumer/target/classes -type f -print
Maven 3.9.16 and Maven Compiler Plugin 3.15.0 are the pinned client
assumptions. The isolated .lab-m2 prevents this
exercise from being mistaken for success caused by a previously
installed copy in the normal Maven Local cache.
10. Optional disposable OpenPGP signing lane
If GnuPG is installed, generate a training-only key in a
project-local home. This key has no production trust and must never
be reused. The build activates signing only when
-PlabSign=true is supplied.
if command -v gpg >/dev/null 2>&1; then
mkdir -p "$PWD/.lab-gnupg"
chmod 700 "$PWD/.lab-gnupg"
gpg --batch --homedir "$PWD/.lab-gnupg" \
--passphrase '' --quick-gen-key \
'DevOps Academy Lab <lab@example.invalid>' ed25519 sign 1d
KEYID="$(gpg --batch --homedir "$PWD/.lab-gnupg" --with-colons --list-secret-keys \
| awk -F: '$1=="sec" {print $5; exit}')"
./gradlew -p publisher \
-PlabSign=true \
-Psigning.gnupg.homeDir="$PWD/.lab-gnupg" \
-Psigning.gnupg.keyName="$KEYID" \
publishMavenJavaPublicationToStagingMavenRepository
find publisher/build/repositories/staging-maven -name '*.asc' -print
fi
Do not replace this training key with a real key in the repository. Production signing keys/passphrases belong in controlled secret/key-management systems and should only be exposed to authorized release jobs.
11. Challenge: which state should change?
You want to add a -sources.jar for IDE consumers
without changing the runtime library API. Which control should you
choose: create a second arbitrary Maven coordinate, attach a sources
artifact to the existing Java component/publication, or copy the
sources ZIP manually into the repository? Predict the generated
metadata and repository-file changes before trying it.
Target reasoning: use the Java component’s
withSourcesJar() support so publishing understands the
artifact relationship. Manual repository edits bypass publication
metadata and reproducibility controls.
12. Cleanup
After completing the checkpoint or this guided lab, remove only the disposable workspace and lab-specific GPG/Maven/Gradle state created underneath it.
cd ..
rm -rf ch26-publishing-lab
Knowledge check
Which task generates the Maven POM without uploading it?
generatePomFileForMavenJavaPublication for the
publication named mavenJava.
Does publish include
publishToMavenLocal?
No. The Maven Local aggregate is separate from the aggregate
that targets repositories declared under
publishing.repositories.
Why use a separate Gradle consumer instead of
implementation(project(":publisher"))?
A project dependency bypasses the published repository metadata. An external coordinate proves what actual repository consumers receive.
Why isolate Maven with
-Dmaven.repo.local=.lab-m2?
It prevents a normal Maven Local cache from hiding missing or stale publication files and keeps the lab state disposable.
If the optional signing key is generated successfully, where should it be stored after the lab?
Nowhere. It is training-only disposable material and should be deleted with the workspace.
Official references and version notes
- Gradle 9.7.1 release notes — pinned Gradle baseline for this chapter.
- Publishing a project as a module — publication = artifacts + metadata, repository target, publish task, checksums, and signatures when configured.
-
Maven Publish Plugin
—
MavenPublication, generated POM, repository tasks, Maven Local, version mapping, snapshot/release routing. -
Ivy Publish Plugin
—
IvyPublication, generatedivy.xml, Ivy repository layout and tasks. - Gradle Module Metadata — variant-aware metadata, POM/Ivy mapping, publication warnings, validation, reproducibility.
- Signing Plugin — OpenPGP signatures, in-memory keys, GPG command integration, signing publications.
- Supported repository protocols and credentials — file/HTTP(S) transports, externalized credentials, repository-name-derived properties.
- Metadata formats — Gradle Module Metadata, Maven POM, Ivy descriptors and consumer behavior.
- Apache Maven releases history — Maven 3.9.16 is the current GA baseline used for the clean Maven consumer lane.
- Maven Compiler Plugin — pinned 3.15.0 compiler plugin for the Maven consumer fixture.
Version-sensitive behavior was rechecked against current Gradle and
Apache Maven primary documentation on 2026-08-24. Mandatory
publication uses only repository-owned files plus disposable
file: repositories under the lab workspace. Maven
Local, real credentials, real release repositories, hosted signing,
Maven Central, Nexus/Artifactory administration, and CI-specific
secret stores are not mandatory.
Keep the academy open
Support free, practical DevOps education.
Every lesson is designed to remain readable in a browser, downloadable from GitHub, and usable without a paid learning platform. Contributions help expand and maintain the curriculum.
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0
Send only Ethereum/ERC-20 compatible assets to this
address.