Chapter 12Lesson 05~230 minutes

Checkpoint Lab — Maven Packaging, install, deploy, Distribution Management, Signing, and Repository Publishing

Publish a harmless Maven artifact to disposable repository state, prove exact coordinates and content identity, capture checksum/signature evidence, consume from a fresh local repository, and reproduce one authentication/ID failure safely.

Checkpoint LabPublicationClean ConsumerSignature EvidenceAuthentication

This checkpoint is complete only when you can explain the identity of the source, build, artifact, repository coordinate, and consumer independently. You will publish one harmless final artifact, verify its bytes and signature evidence, consume it from a clean repository, and then reproduce an authentication/server-ID failure without using any real credential.

Learning objectives

  • Record source revision, Maven/JDK identity, project version, and artifact checksum.
  • Compare package, install, and deploy side effects with separate isolated repositories.
  • Publish main/source/Javadoc artifacts to a disposable repository and verify layout/metadata.
  • Generate and verify detached signature evidence with a disposable key.
  • Consume from clean state and diagnose a loopback repository/server-ID authentication failure.
Current baseline — verified 2026-08-24. Labs use Maven 3.9.16 via Maven Wrapper 3.3.4, JDK 21, Java 17 target, Compiler 3.15.0, JAR 3.5.1, Install/Deploy 3.1.4, Source 3.4.0, Javadoc 3.12.0, Help 3.5.2, and GPG 3.2.8. Publication targets and local Maven repositories are disposable project-relative directories. No real repository, signing key, global settings file, production CI secret, or normal user cache is modified.
Cross-platform note: POSIX examples use ./mvnw, find, sha256sum, and rm -rf only for the named disposable lab directory. On Windows use mvnw.cmd, Get-ChildItem, Get-FileHash -Algorithm SHA256, and Remove-Item -Recurse on that same disposable directory. For a Windows file-repository target, convert the directory to an absolute file:///C:/...-style URI (for example with PowerShell's [uri]$path/AbsoluteUri) and pass it through the lab repository property instead of copying the POSIX file:// form literally. Never substitute a normal Maven cache, home directory, or shared repository path.

1. Checkpoint acceptance contract

Invariant Required evidence
Source identity is recorded Local Git commit SHA plus Maven/JDK version evidence.
Project identity is exact dev.academy.publish:hello-publisher:1.0.0 captured from Maven model.
Package does not install this project Main target JAR exists; project coordinate absent from package-only repository.
Install adds local project coordinate POM/main/source/Javadoc artifacts visible under isolated local repository.
Deploy publishes same main bytes File-repository JAR hash equals build-output JAR hash.
Signature evidence verifies Synthetic Ed25519 detached signature verifies against published bytes.
Clean consumer resolves publication Fresh consumer repository obtains coordinate from disposable release repository.
Wrong server ID fails safely Loopback authenticated target returns a Maven deployment failure until server ID is corrected.
Cleanup is scoped Only checkpoint directories and loopback process are removed.

2. Setup, preflight, and source commit

Run this from a trusted wrapper-enabled disposable Maven project whose Wrapper configuration was already reviewed in Chapter 04. The checkpoint copies that wrapper unchanged so the build-tool identity is part of the evidence instead of an unreviewed download.

set -euo pipefail
mkdir -p ../maven-publish-checkpoint/src/main/java/dev/academy/publish ../maven-publish-checkpoint/{evidence,tools}
cp mvnw mvnw.cmd ../maven-publish-checkpoint/
cp -R .mvn ../maven-publish-checkpoint/
cd ../maven-publish-checkpoint
./mvnw -v | tee evidence/00-maven-version.txt
java -version 2> evidence/00-java-version.txt
javac -version 2> evidence/00-javac-version.txt || javac -version | tee evidence/00-javac-version.txt

git init -q
git config user.name "DevOps Academy Lab"
git config user.email "lab@example.invalid"
# Save pom.xml and Greeting.java from this lesson before committing.
git add pom.xml src

git commit -qm "checkpoint source"
git rev-parse HEAD | tee evidence/01-source-commit.txt
./mvnw -Dmaven.repo.local=.model-m2 help:evaluate -Dexpression=project.version -q -DforceStdout   | tee evidence/02-project-version.txt

The Git identity is local to the disposable repository and uses a reserved invalid email domain. It does not touch global Git configuration. The Wrapper must still report Maven 3.9.16; stop if the copied wrapper has drifted.

3. Project, consumer, and verification tools

<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.publish</groupId>
  <artifactId>hello-publisher</artifactId>
  <version>1.0.0</version>

  <properties>
    <maven.compiler.release>17</maven.compiler.release>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    <project.build.outputTimestamp>2026-08-24T00:00:00Z</project.build.outputTimestamp>
    <lab.release.repo>file://${project.basedir}/.lab-remote/releases</lab.release.repo>
    <lab.snapshot.repo>file://${project.basedir}/.lab-remote/snapshots</lab.snapshot.repo>
  </properties>

  <distributionManagement>
    <repository>
      <id>lab-releases</id>
      <url>${lab.release.repo}</url>
    </repository>
    <snapshotRepository>
      <id>lab-snapshots</id>
      <url>${lab.snapshot.repo}</url>
    </snapshotRepository>
  </distributionManagement>

  <build>
    <plugins>
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-compiler-plugin</artifactId>
        <version>3.15.0</version>
      </plugin>
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-jar-plugin</artifactId>
        <version>3.5.1</version>
      </plugin>
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-source-plugin</artifactId>
        <version>3.4.0</version>
        <executions>
          <execution>
            <id>attach-sources</id>
            <phase>verify</phase>
            <goals><goal>jar-no-fork</goal></goals>
          </execution>
        </executions>
      </plugin>
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-javadoc-plugin</artifactId>
        <version>3.12.0</version>
        <executions>
          <execution>
            <id>attach-javadocs</id>
            <phase>verify</phase>
            <goals><goal>jar</goal></goals>
          </execution>
        </executions>
      </plugin>
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-install-plugin</artifactId>
        <version>3.1.4</version>
      </plugin>
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-deploy-plugin</artifactId>
        <version>3.1.4</version>
      </plugin>
    </plugins>
  </build>

  <profiles>
    <profile>
      <id>release-sign</id>
      <build>
        <plugins>
          <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-gpg-plugin</artifactId>
            <version>3.2.8</version>
            <executions>
              <execution>
                <id>sign-artifacts</id>
                <phase>verify</phase>
                <goals><goal>sign</goal></goals>
              </execution>
            </executions>
          </plugin>
        </plugins>
      </build>
    </profile>
  </profiles>
</project>
package dev.academy.publish;

/** Small deterministic class used only by the publishing lab. */
public final class Greeting {
    private Greeting() {}

    /** Returns a stable greeting for artifact inspection and consumption. */
    public static String message() {
        return "hello-from-published-artifact";
    }
}
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.KeyFactory;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.PublicKey;
import java.security.Signature;
import java.security.spec.X509EncodedKeySpec;
import java.util.Base64;

public final class DetachedSigner {
    public static void main(String[] args) throws Exception {
        if (args.length != 4) throw new IllegalArgumentException("sign|verify artifact public-key signature");
        Path artifact = Path.of(args[1]);
        Path publicFile = Path.of(args[2]);
        Path signatureFile = Path.of(args[3]);
        byte[] data = Files.readAllBytes(artifact);
        if ("sign".equals(args[0])) {
            KeyPairGenerator generator = KeyPairGenerator.getInstance("Ed25519");
            KeyPair pair = generator.generateKeyPair();
            Signature signer = Signature.getInstance("Ed25519");
            signer.initSign(pair.getPrivate());
            signer.update(data);
            Files.writeString(publicFile, Base64.getEncoder().encodeToString(pair.getPublic().getEncoded()));
            Files.writeString(signatureFile, Base64.getEncoder().encodeToString(signer.sign()));
            System.out.println("SIGNATURE_CREATED");
        } else if ("verify".equals(args[0])) {
            byte[] publicBytes = Base64.getDecoder().decode(Files.readString(publicFile).trim());
            PublicKey publicKey = KeyFactory.getInstance("Ed25519").generatePublic(new X509EncodedKeySpec(publicBytes));
            Signature verifier = Signature.getInstance("Ed25519");
            verifier.initVerify(publicKey);
            verifier.update(data);
            boolean ok = verifier.verify(Base64.getDecoder().decode(Files.readString(signatureFile).trim()));
            System.out.println(ok ? "SIGNATURE_OK" : "SIGNATURE_BAD");
            if (!ok) System.exit(2);
        } else throw new IllegalArgumentException("first argument must be sign or verify");
    }
}
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.Base64;

public final class RepoServer {
    public static void main(String[] args) throws Exception {
        Path root = Path.of(args.length > 0 ? args[0] : "repo-store").toAbsolutePath().normalize();
        int port = args.length > 1 ? Integer.parseInt(args[1]) : 8765;
        String user = System.getenv().getOrDefault("MAVEN_LAB_REPO_USER", "lab-user");
        String pass = System.getenv().getOrDefault("MAVEN_LAB_REPO_PASS", "lab-pass");
        String wanted = "Basic " + Base64.getEncoder().encodeToString((user + ":" + pass).getBytes(StandardCharsets.UTF_8));
        Files.createDirectories(root);
        HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", port), 0);
        server.createContext("/repository/releases", ex -> handle(ex, root, wanted));
        server.start();
        System.out.println("READY http://127.0.0.1:" + port + "/repository/releases");
    }

    private static void handle(HttpExchange ex, Path root, String wanted) throws IOException {
        String auth = ex.getRequestHeaders().getFirst("Authorization");
        if (!wanted.equals(auth)) {
            ex.getResponseHeaders().add("WWW-Authenticate", "Basic realm=lab");
            ex.sendResponseHeaders(401, -1);
            ex.close();
            return;
        }
        String suffix = ex.getRequestURI().getPath().substring("/repository/releases".length());
        while (suffix.startsWith("/")) suffix = suffix.substring(1);
        Path target = root.resolve(suffix).normalize();
        if (!target.startsWith(root)) {
            ex.sendResponseHeaders(400, -1); ex.close(); return;
        }
        String method = ex.getRequestMethod();
        if ("PUT".equals(method)) {
            Files.createDirectories(target.getParent());
            Files.copy(ex.getRequestBody(), target, StandardCopyOption.REPLACE_EXISTING);
            ex.sendResponseHeaders(201, -1);
        } else if ("GET".equals(method) || "HEAD".equals(method)) {
            if (!Files.isRegularFile(target)) { ex.sendResponseHeaders(404, -1); }
            else {
                long size = Files.size(target);
                ex.sendResponseHeaders(200, "HEAD".equals(method) ? -1 : size);
                if ("GET".equals(method)) Files.copy(target, ex.getResponseBody());
            }
        } else if ("OPTIONS".equals(method)) {
            ex.sendResponseHeaders(200, -1);
        } else {
            ex.sendResponseHeaders(405, -1);
        }
        ex.close();
    }
}

4. Predict before execution

Write these predictions into evidence/predictions.txt before running Maven:

  1. package will create the main JAR but the project coordinate will be absent from .package-m2/dev/academy/publish/....
  2. install will add the project coordinate to .install-m2, including attached sources/Javadoc because their executions occur at verify.
  3. deploy will add repository-layout files under .lab-remote/releases and the published main JAR hash will equal the build-output JAR hash from that run.
  4. A fresh consumer repository will resolve the project from .lab-remote/releases; a stale repository is not acceptable evidence.

5. Compare package and install side effects

set -euo pipefail
rm -rf .package-m2 target
./mvnw -Dmaven.repo.local=.package-m2 clean package | tee evidence/03-package.log
sha256sum target/hello-publisher-1.0.0.jar | tee evidence/04-package-sha256.txt
if test -e .package-m2/dev/academy/publish/hello-publisher/1.0.0/hello-publisher-1.0.0.jar; then
  echo 'FAIL: package unexpectedly installed project coordinate' >&2; exit 3
fi
set -euo pipefail
rm -rf .install-m2
./mvnw -Dmaven.repo.local=.install-m2 clean install | tee evidence/05-install.log
find .install-m2/dev/academy/publish/hello-publisher/1.0.0 -maxdepth 1 -type f -print   | sort | tee evidence/06-installed-files.txt

Do not compare total repository sizes: plugin/dependency resolution differs between runs. Compare the specific project-coordinate path and attached classifiers.

6. Deploy to the disposable release repository and prove byte identity

set -euo pipefail
rm -rf .deploy-m2 .lab-remote target
./mvnw -Dmaven.repo.local=.deploy-m2 clean deploy | tee evidence/07-deploy.log
published=.lab-remote/releases/dev/academy/publish/hello-publisher/1.0.0/hello-publisher-1.0.0.jar
sha256sum target/hello-publisher-1.0.0.jar | tee evidence/08-built-sha256.txt
sha256sum "$published" | tee evidence/09-published-sha256.txt
cmp --silent target/hello-publisher-1.0.0.jar "$published"
find .lab-remote/releases/dev/academy/publish/hello-publisher -type f -print   | sort | tee evidence/10-publication-layout.txt

Also inspect the POM plus -sources and -javadoc artifacts in the release directory. Maven may create checksum/metadata sidecars according to repository transport behavior; your independently recorded SHA-256 remains the checkpoint’s explicit byte-identity evidence.

7. Generate detached signature evidence without persisting a private key

set -euo pipefail
javac --release 17 -d tools tools/DetachedSigner.java
published=.lab-remote/releases/dev/academy/publish/hello-publisher/1.0.0/hello-publisher-1.0.0.jar
java -cp tools DetachedSigner sign "$published" evidence/public-key.b64 evidence/published.sig.b64   | tee evidence/11-sign.txt
java -cp tools DetachedSigner verify "$published" evidence/public-key.b64 evidence/published.sig.b64   | tee evidence/12-signature-verification.txt
grep -qx 'SIGNATURE_OK' evidence/12-signature-verification.txt

The generated private key is process-local and discarded. The checkpoint therefore demonstrates cryptographic verification without creating a reusable release identity. Production signing would use Maven GPG Plugin 3.2.8 or an organization-approved signing service/agent with controlled key custody.

8. Consume from a clean isolated 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>clean-consumer</artifactId>
  <version>1.0.0</version>
  <properties>
    <maven.compiler.release>17</maven.compiler.release>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
  </properties>
  <repositories>
    <repository>
      <id>training-releases</id>
      <url>file://${project.basedir}/../.lab-remote/releases</url>
      <releases><enabled>true</enabled></releases>
      <snapshots><enabled>false</enabled></snapshots>
    </repository>
  </repositories>
  <dependencies>
    <dependency>
      <groupId>dev.academy.publish</groupId>
      <artifactId>hello-publisher</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.Greeting;

public final class Consumer {
    public static void main(String[] args) {
        System.out.println(Greeting.message());
    }
}
set -euo pipefail
mkdir -p consumer/src/main/java/dev/academy/consumer
# Save consumer POM/source above.
rm -rf .consumer-m2
./mvnw -Dmaven.repo.local=.consumer-m2 -f consumer/pom.xml clean package   | tee evidence/13-consumer-build.log
java -cp "consumer/target/classes:.consumer-m2/dev/academy/publish/hello-publisher/1.0.0/hello-publisher-1.0.0.jar"   dev.academy.consumer.Consumer | tee evidence/14-consumer-run.txt
grep -qx 'hello-from-published-artifact' evidence/14-consumer-run.txt

9. Simulate and repair one authentication/repository-ID failure

This failure uses only loopback networking and lab-only values. First replace the project’s release distributionManagement with the loopback fragment below and save the intentionally wrong settings file.

<distributionManagement>
  <repository>
    <id>lab-auth-repo</id>
    <url>http://127.0.0.1:8765/repository/releases</url>
  </repository>
</distributionManagement>
<settings xmlns="http://maven.apache.org/SETTINGS/1.2.0"
          xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
          xsi:schemaLocation="http://maven.apache.org/SETTINGS/1.2.0 https://maven.apache.org/xsd/settings-1.2.0.xsd">
  <servers>
    <server>
      <id>wrong-server-id</id>
      <username>${env.MAVEN_LAB_REPO_USER}</username>
      <password>${env.MAVEN_LAB_REPO_PASS}</password>
    </server>
  </servers>
</settings>
set -euo pipefail
javac --release 17 -d tools tools/RepoServer.java
export MAVEN_LAB_REPO_USER=lab-user
export MAVEN_LAB_REPO_PASS=lab-pass
java -cp tools RepoServer .auth-repo 8765 > evidence/15-auth-server.log 2>&1 &
server_pid=$!
trap 'kill "$server_pid" 2>/dev/null || true' EXIT
sleep 1

set +e
./mvnw -s .lab-settings.xml -Dmaven.repo.local=.auth-fail-m2 clean deploy   > evidence/16-auth-failure.log 2>&1
status=$?
set -e
printf 'wrong-id exit=%s
' "$status" | tee evidence/17-auth-failure-exit.txt
test "$status" -ne 0

# Change ONLY wrong-server-id -> lab-auth-repo in .lab-settings.xml.
rm -rf .auth-fixed-m2
./mvnw -s .lab-settings.xml -Dmaven.repo.local=.auth-fixed-m2 clean deploy   | tee evidence/18-auth-fixed.log
find .auth-repo -type f -print | sort | tee evidence/19-auth-repo-layout.txt
The values lab-user/lab-pass exist only inside the disposable loopback exercise. They are not examples of production secrets. Do not copy real repository credentials into the POM, URL, evidence directory, or Git history.

10. Verification checklist

  • Source commit, Maven/JDK identity, and project version are recorded.
  • Package produced a main JAR and did not install this project coordinate.
  • Install wrote the project POM/main/source/Javadoc artifacts under the isolated coordinate path.
  • Deploy wrote the same main JAR bytes to the disposable release repository.
  • SHA-256 evidence for build and publication matches.
  • Detached Ed25519 signature verification prints SIGNATURE_OK.
  • A fresh consumer repository resolves and runs the published artifact.
  • The intentionally wrong settings server ID produces a non-zero deployment failure.
  • Changing only the server ID restores authenticated loopback publication.
  • No normal Maven cache, global settings, production repository, real credential, or durable signing key is touched.

11. Cleanup and rollback

kill "$server_pid" 2>/dev/null || true
trap - EXIT
cd ..
rm -rf maven-publish-checkpoint

If evidence must be retained for a training review, copy only the evidence/ directory after checking that it contains no credential value. Delete all disposable publication repositories and synthetic key/signature tools afterward.

Knowledge check

Why does the checkpoint record both source commit and artifact SHA-256?

What observable side effect distinguishes install from package?

Why is a fresh consumer repository mandatory evidence?

Why does the wrong server ID produce an authentication failure even though credentials exist in settings?

What is missing from the synthetic Ed25519 signature before it can represent organizational release trust?

What production capability does Chapter 12 add?

12. Production operating model and Chapter 13 bridge

Chapter 12 adds controlled artifact publication to the build-engineering operating model. A team can now prove which source and toolchain produced which bytes, where those bytes were installed or deployed, and which credential/signing boundary authorized publication. Chapter 13 builds on that evidence to study Maven performance, parallel builds, daemon options, reproducible builds, and systematic troubleshooting without sacrificing artifact identity.

Official references and version notes

Version-sensitive statements were checked against Apache Maven primary documentation on 2026-08-24. The mandatory Maven path pins Maven 3.9.16 via Maven Wrapper 3.3.4, JDK 21 to run Maven, Java 17 as the compiler release target, Compiler Plugin 3.15.0, JAR Plugin 3.5.1, Install Plugin 3.1.4, Deploy Plugin 3.1.4, Source Plugin 3.4.0, Javadoc Plugin 3.12.0, Help Plugin 3.5.2, and GPG Plugin 3.2.8.

Maven 4 remains preview-stage in the current Apache download page, so this chapter does not silently switch publishing semantics to Maven 4.

The checkpoint intentionally uses local file and loopback HTTP repositories. Repository-manager staging, retention, access administration, and enterprise promotion workflows belong to the dedicated artifact-repository/platform courses.

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.