Chapter 26Lesson 05~365 minutes

Checkpoint Lab — Gradle Publishing, Maven Publish, Ivy Publish, Signing, Metadata, and Repository Promotion

Publish one immutable Java library into a disposable staging repository, inspect POM/GMM/checksums and optional signatures, promote the exact bytes without rebuilding, then prove both Gradle and Maven consumers resolve the same coordinate and artifact identity.

CheckpointMaven + GradleSHA-256PromotionClean consumers

Learning objectives

  • Publish an exact release candidate to a disposable Maven staging repository and record its coordinate and SHA-256 evidence.
  • Inspect POM, GMM, source/Javadoc artifacts, repository checksums, and optional detached signatures before promotion.
  • Promote the already-built coordinate tree without rebuilding or overwriting an existing release.
  • Resolve and compile against the promoted coordinate from clean Gradle and Maven consumers.
  • Inject a fake credential/URL failure, preserve evidence, restore the safe repository model, and clean only disposable state.

1. Checkpoint acceptance contract

The checkpoint is complete only when all of these statements are independently supported by evidence:

  • The publisher uses the trusted Gradle 9.7.1 Wrapper, JDK 21 toolchain, Java 17 target, and coordinate dev.academy.publish:ledger-api:1.0.0.
  • The staged Maven repository contains the binary, sources, Javadoc, POM, GMM, and checksums.
  • The binary staged JAR SHA-256 matches the build output.
  • Promotion copies the staged coordinate to a separate release directory only if that coordinate does not already exist.
  • The promoted binary SHA-256 equals the staged binary SHA-256.
  • A clean Gradle consumer resolves the promoted external module and runs.
  • A clean Maven consumer compiles against the same coordinate using an isolated Maven local repository.
  • The fake secure repository experiment fails for the expected credential/URL layers and is removed.
  • If optional signing is used, .asc files are present and the key is disposable.

2. Create the workspace and publisher

Start in a disposable wrapper-enabled workspace. Create the publisher with the same files from Lesson 2.

mkdir -p ch26-checkpoint
cd ch26-checkpoint

# Copy the already-verified Gradle Wrapper files here before continuing.
test -f ./gradlew
export GRADLE_USER_HOME="$PWD/.lab-gradle-home"

mkdir -p publisher/src/main/java/dev/academy/publish
cat > publisher/settings.gradle.kts <<'EOF'
rootProject.name = "ledger-api"
EOF
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;
    }
}

Save the Kotlin DSL as publisher/build.gradle.kts and the Java source as publisher/src/main/java/dev/academy/publish/LedgerApi.java.

3. Preflight: predict first, then inspect

Write down these predictions before running the commands:

  1. After metadata generation but before publication, build/publications changes but the staging repository does not.
  2. After Maven publication, the repository contains a coordinate tree under dev/academy/publish/ledger-api/1.0.0.
  3. A clean Gradle consumer will resolve an external module from GMM/POM metadata; a Maven consumer will use the POM.
  4. Promotion should not change the JAR SHA-256.
java -version
./gradlew --version
./gradlew -p publisher outgoingVariants
./gradlew -p publisher tasks --group publishing

test ! -e publisher/build/repositories/staging-maven
test ! -e publisher/build/repositories/release-maven

4. Build and publish one candidate

Use one publish task to build and place the Maven publication into staging. Do not run a second “release build” later.

./gradlew -p publisher clean \
  publishMavenJavaPublicationToStagingMavenRepository

COORD="publisher/build/repositories/staging-maven/dev/academy/publish/ledger-api/1.0.0"
test -f "$COORD/ledger-api-1.0.0.jar"
test -f "$COORD/ledger-api-1.0.0-sources.jar"
test -f "$COORD/ledger-api-1.0.0-javadoc.jar"
test -f "$COORD/ledger-api-1.0.0.pom"
test -f "$COORD/ledger-api-1.0.0.module"

find "$COORD" -type f -print | sort

If any required file is absent, stop and repair the component/publication. Do not promote an incomplete repository tree.

5. Inspect the contract and checksum evidence

Inspect both native Maven metadata and GMM. Maven consumers will not read the GMM file, so the POM must still identify the correct coordinate and dependency surface.

cat "$COORD/ledger-api-1.0.0.pom"
cat "$COORD/ledger-api-1.0.0.module"
jar tf "$COORD/ledger-api-1.0.0.jar"
jar tf "$COORD/ledger-api-1.0.0-sources.jar"

sha256sum publisher/build/libs/ledger-api-1.0.0.jar \
  "$COORD/ledger-api-1.0.0.jar" \
  | tee publisher/build/candidate-binary.sha256

sha256sum "$COORD"/* > publisher/build/candidate-tree.sha256

The two binary JAR digests must match. Repository-generated checksum files can also be inspected, but the independent sha256sum output is the checkpoint’s own evidence.

6. Optional signature evidence

If GnuPG is available, perform the disposable signing lane from Lesson 2 before promotion. After republishing the signed publication to staging, record the .asc files and verify one signature with the disposable keyring.

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 Checkpoint <checkpoint@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 "$COORD" -name '*.asc' -print | sort
  gpg --batch --homedir "$PWD/.lab-gnupg" \
      --verify "$COORD/ledger-api-1.0.0.jar.asc" "$COORD/ledger-api-1.0.0.jar"
fi

Signing is optional because GnuPG may not be installed. If skipped, record “signature lane not executed” rather than claiming a signature exists.

7. Promote exact bytes with an overwrite guard

The local promotion simulation deliberately does not invoke Gradle. It copies the already-published repository tree and refuses to overwrite an existing release directory.

STAGING="publisher/build/repositories/staging-maven"
RELEASE="publisher/build/repositories/release-maven"
RELEASE_COORD="$RELEASE/dev/academy/publish/ledger-api/1.0.0"

test ! -e "$RELEASE_COORD" || { echo 'REFUSE: 1.0.0 already released'; exit 1; }
mkdir -p "$RELEASE"
cp -R "$STAGING"/. "$RELEASE"/

sha256sum \
  "$STAGING/dev/academy/publish/ledger-api/1.0.0/ledger-api-1.0.0.jar" \
  "$RELEASE_COORD/ledger-api-1.0.0.jar"

The digests must be identical. In production, a repository manager should perform this transition with immutable storage, permissions, and audit logs instead of a filesystem copy.

8. Prove the promoted coordinate with a clean Gradle consumer

Create the Gradle consumer files from Lesson 2, then resolve only from the promoted repository and an isolated Gradle User Home.

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));
    }
}
GRADLE_USER_HOME="$PWD/.consumer-gradle-home" \
./gradlew -p gradle-consumer \
  -PpublicationRepo="$PWD/publisher/build/repositories/release-maven" \
  dependencies --configuration runtimeClasspath \
  | tee gradle-consumer/resolution.txt

GRADLE_USER_HOME="$PWD/.consumer-gradle-home" \
./gradlew -p gradle-consumer \
  -PpublicationRepo="$PWD/publisher/build/repositories/release-maven" \
  run | tee gradle-consumer/run.txt

grep 'dev.academy.publish:ledger-api:1.0.0' gradle-consumer/resolution.txt
grep 'acct-7:4250' gradle-consumer/run.txt

9. Prove the same coordinate with Maven

Create the Maven consumer files from Lesson 2. Use Maven 3.9.16 with an isolated local repository. The build may download the pinned compiler plugin from Maven Central if it is not already cached; the published library itself must come from the promoted file repository.

<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/release-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/.consumer-m2" \
  -DskipTests compile \
  | tee maven-consumer/compile.log

test -f maven-consumer/target/classes/dev/academy/consumer/Main.class
find .consumer-m2/dev/academy/publish/ledger-api/1.0.0 -type f -print | sort
sha256sum \
  .consumer-m2/dev/academy/publish/ledger-api/1.0.0/ledger-api-1.0.0.jar \
  "$RELEASE_COORD/ledger-api-1.0.0.jar"

The cached Maven-consumer JAR and promoted repository JAR must match. This proves Maven read the POM/repository layout for the same external identity Gradle consumed.

10. Inject and diagnose a credential/URL failure

Temporarily add the secureDemo repository block from Lesson 4 to publisher/build.gradle.kts. First run with no credentials, then with fake credentials. Preserve the two different error layers.

maven {
    name = "secureDemo"
    url = uri("https://127.0.0.1:9/repository")
    credentials(org.gradle.api.credentials.PasswordCredentials::class)
}
set +e
./gradlew -p publisher publishMavenJavaPublicationToSecureDemoRepository \
  > secure-demo-missing.log 2>&1
missing_rc=$?
./gradlew -p publisher \
  -PsecureDemoUsername=lab-user -PsecureDemoPassword=lab-password \
  publishMavenJavaPublicationToSecureDemoRepository \
  > secure-demo-url.log 2>&1
url_rc=$?
set -e

printf 'missing credentials rc=%s; bad URL rc=%s\n' "$missing_rc" "$url_rc"
test "$missing_rc" -ne 0
test "$url_rc" -ne 0

grep -Ei 'credential|username|password' secure-demo-missing.log || true
grep -Ei 'connect|refused|127\.0\.0\.1' secure-demo-url.log || true

Remove the temporary repository block and rerun ./gradlew -p publisher tasks --group publishing to prove the unsafe target is no longer part of the model.

11. Prove the release overwrite guard

Run the promotion guard a second time without deleting the release directory. It must fail before copying anything. That is the checkpoint’s local simulation of release immutability.

set +e
test ! -e "$RELEASE_COORD" || { echo 'REFUSE: 1.0.0 already released'; exit 23; }
rc=$?
set -e
printf 'overwrite-guard rc=%s\n' "$rc"
test "$rc" -eq 23

In a real repository manager, this policy belongs on the server as well. A client-side guard is defense in depth, not the authoritative control.

12. Verification checklist

Evidence Pass condition
Tool identity Gradle 9.7.1 Wrapper; JDK 21; Java target 17; Maven 3.9.16 for Maven lane
Coordinate dev.academy.publish:ledger-api:1.0.0 appears in staged/release paths and both consumers
Artifacts Binary, sources, Javadoc, POM, GMM and checksums present
Binary identity Build output = staged JAR = promoted JAR = Maven consumer cached JAR by SHA-256
Gradle consumer External module resolves and application prints acct-7:4250
Maven consumer Compile succeeds against promoted file repo with isolated .consumer-m2
Signing Optional: .asc exists and verifies with disposable key; otherwise explicitly recorded as skipped
Failure evidence Missing-credential and bad-loopback-URL cases fail separately with fake data
Immutability Second promotion attempt refuses existing 1.0.0

13. Cleanup and rollback

Remove only the disposable checkpoint workspace. This deletes the lab Gradle/Maven caches, file repositories, fake logs, and disposable GPG key without touching normal user state.

cd ..
rm -rf ch26-checkpoint

Do not delete normal ~/.gradle, ~/.m2, or ~/.gnupg. The mandatory lab never needed to mutate them.

14. What Chapter 26 adds to the operating model

You can now treat a build artifact as an externally governed module: component semantics become POM/Ivy/GMM metadata, repository writes are credentialed trust transitions, signatures are optional cryptographic evidence, and promotion preserves exact artifact identity instead of rebuilding. Chapter 27 builds on this by treating reusable Gradle build logic itself as a versioned, tested plugin product with TestKit and compatibility policy.

Knowledge check

Why does the checkpoint hash the JAR before and after promotion?

Which consumer demonstrates whether the POM alone is sufficient?

Why must the second promotion attempt fail?

If Gradle resolves but Maven does not, should you add mavenLocal() until it works?

What does a successful OpenPGP signature prove by itself?

What is Chapter 27’s natural next boundary?

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.