Chapter 26Lesson 02~340 minutes

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.

File repositoriesPOMivy.xmlSources/JavadocConsumers

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?

Does publish include publishToMavenLocal?

Why use a separate Gradle consumer instead of implementation(project(":publisher"))?

Why isolate Maven with -Dmaven.repo.local=.lab-m2?

If the optional signing key is generated successfully, where should it be stored after the lab?

Official references and version notes

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.

Ethereum / ERC-20
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0 Send only Ethereum/ERC-20 compatible assets to this address.