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.
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,
.ascfiles 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:
-
After metadata generation but before publication,
build/publicationschanges but the staging repository does not. -
After Maven publication, the repository contains a coordinate tree
under
dev/academy/publish/ledger-api/1.0.0. - A clean Gradle consumer will resolve an external module from GMM/POM metadata; a Maven consumer will use the POM.
- 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?
To prove promotion moved/exposed the exact already-built bytes rather than producing a second artifact under the same version.
Which consumer demonstrates whether the POM alone is sufficient?
The Maven consumer, because Maven does not consume Gradle Module Metadata.
Why must the second promotion attempt fail?
A released non-snapshot coordinate must be immutable; an overwrite would make one version identify different byte sequences.
If Gradle resolves but Maven does not, should you add
mavenLocal() until it works?
No. That can hide missing repository metadata with mutable local state. Compare POM/GMM and use isolated consumer caches.
What does a successful OpenPGP signature prove by itself?
That the signed bytes verify against a key under your trust policy. It does not prove vulnerability status, reproducibility, authorization, or deployment safety.
What is Chapter 27’s natural next boundary?
Packaging reusable build policy itself as tested Gradle plugins/convention logic with explicit compatibility and publication boundaries.
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.