Chapter 12Lesson 02~205 minutes

Maven Packaging, install, deploy, Distribution Management, Signing, and Repository Publishing: Guided Hands-On Workflow and Core Operations

Build and publish a harmless Maven JAR through package, verify, install, and deploy using isolated local state, a file-backed repository, attached sources/Javadocs, checksums, and a synthetic detached-signature path.

JARSourcesJavadocFile RepositoryChecksums

This workflow turns the model into evidence. Every mutation stays under one disposable directory, and every important command is paired with a prediction about target/, the isolated local repository, or the file-backed publication repository.

Learning objectives

  • Author a minimal publishable Maven project with pinned publishing-related plugins.
  • Inspect the main JAR and attached sources/Javadoc artifacts.
  • Compare package, install, verify, and deploy side effects with isolated repositories.
  • Publish to a local file repository and inspect coordinates, metadata, and checksums.
  • Create and verify a disposable detached signature without storing a long-lived private key.
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. Create the disposable workspace

Start from the root of a trusted wrapper-enabled disposable Maven project created in Chapter 04. Copy the already-reviewed Wrapper scripts and .mvn/ configuration into this new lab; do not download or regenerate a different wrapper just for this exercise.

set -euo pipefail
mkdir -p ../maven-publish-lab/src/main/java/dev/academy/publish ../maven-publish-lab/{evidence,tools}
cp mvnw mvnw.cmd ../maven-publish-lab/
cp -R .mvn ../maven-publish-lab/
cd ../maven-publish-lab
./mvnw -v | tee evidence/maven-version.txt
java -version 2> evidence/java-version.txt
printf '%s
'   'Prediction A: package creates target JAR but does not install this project coordinate.'   'Prediction B: install adds the coordinate to the selected local repository.'   'Prediction C: deploy adds repository-layout files under .lab-remote/releases.'   > evidence/predictions.txt

The copied wrapper must still identify Maven 3.9.16 and the previously reviewed Wrapper 3.3.4 configuration. If it does not, stop and repair the wrapper using Chapter 04 rather than accepting drift.

2. Author the project and pin the publication toolchain

The POM uses a final coordinate dev.academy.publish:hello-publisher:1.0.0. Source and Javadoc attachments are bound at verify, before install/deploy. The file-backed release and snapshot repositories are deliberately local and disposable.

<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";
    }
}

3. Inspect the effective model before mutation

set -euo pipefail
./mvnw -Dmaven.repo.local=.model-m2 help:effective-pom -Dverbose > evidence/effective-pom.xml
./mvnw -Dmaven.repo.local=.model-m2 help:evaluate -Dexpression=project.version -q -DforceStdout   | tee evidence/project-version.txt
grep -nE "maven-deploy-plugin|maven-install-plugin|distributionManagement|lab-releases"   evidence/effective-pom.xml

The .model-m2 repository may receive plugin metadata/artifacts needed for inspection. That is resolution state, not proof that hello-publisher:1.0.0 has been installed.

4. package: inspect the main JAR and prove what did not happen

set -euo pipefail
./mvnw -Dmaven.repo.local=.package-m2 clean package | tee evidence/01-package.log
jar tf target/hello-publisher-1.0.0.jar | tee evidence/02-jar-contents.txt
sha256sum target/hello-publisher-1.0.0.jar | tee evidence/03-package-sha256.txt

test -f target/hello-publisher-1.0.0.jar
if test -e .package-m2/dev/academy/publish/hello-publisher/1.0.0/hello-publisher-1.0.0.jar; then
  echo 'UNEXPECTED: project coordinate was installed during package' >&2
  exit 2
fi

The local repository is not empty because Maven resolves its own build inputs there. The important negative proof is narrower: the current project’s coordinate is absent.

5. verify: attach sources and Javadocs

The project deliberately binds source:jar-no-fork and javadoc:jar at verify. Running verify therefore extends the artifact set without yet requiring a remote publication.

set -euo pipefail
./mvnw -Dmaven.repo.local=.verify-m2 clean verify | tee evidence/04-verify.log
ls -1 target/*.jar | tee evidence/05-attached-artifacts.txt
sha256sum target/*.jar | tee evidence/06-attached-sha256.txt

Expected artifact set: hello-publisher-1.0.0.jar, hello-publisher-1.0.0-sources.jar, and hello-publisher-1.0.0-javadoc.jar. Their classifiers let Maven publish them under the same project coordinate.

6. install: add the project coordinate to an isolated local repository

set -euo pipefail
./mvnw -Dmaven.repo.local=.install-m2 clean install | tee evidence/07-install.log
find .install-m2/dev/academy/publish/hello-publisher/1.0.0 -maxdepth 1 -type f -print   | sort | tee evidence/08-installed-files.txt

The installed path should include the POM and each attached artifact. This is local consumption state only; nothing has yet crossed into .lab-remote.

7. deploy: publish to a disposable file repository

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

deploy passes through install, so .deploy-m2 contains the project coordinate as well. The new trust transition is the repository-layout copy under .lab-remote/releases.

8. Create real cryptographic signature evidence with a disposable JDK key

The mandatory synthetic path below uses JDK Ed25519. The private key exists only in the signing process and is not written to disk; the public key and detached signature are evidence files. This teaches signature verification without pretending it is the PGP trust model used by Maven Central-style release processes.

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");
    }
}
set -euo pipefail
javac --release 17 -d tools tools/DetachedSigner.java
artifact=.lab-remote/releases/dev/academy/publish/hello-publisher/1.0.0/hello-publisher-1.0.0.jar
java -cp tools DetachedSigner sign "$artifact" evidence/lab-public-key.b64 evidence/hello-publisher.sig.b64   | tee evidence/13-sign.txt
java -cp tools DetachedSigner verify "$artifact" evidence/lab-public-key.b64 evidence/hello-publisher.sig.b64   | tee evidence/14-signature-verify.txt
Production signing boundary: Maven GPG Plugin 3.2.8 can sign the project POM and attached artifacts. Production private keys belong in a controlled signing environment or agent, not in the repository, build artifact, or command-line history. The release-sign profile in this lesson is a configuration example; do not activate it with a real organizational key in a training workspace.

9. Consume from a clean isolated repository

A strong publication check uses a consumer whose local repository does not already contain the project coordinate. The repository in this consumer POM points only at the disposable file publication for project artifacts.

<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 the two files above, then:
rm -rf .consumer-m2
./mvnw -Dmaven.repo.local=.consumer-m2 -f consumer/pom.xml clean package   | tee evidence/15-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/16-consumer-run.txt

On Windows use ; instead of : as the Java classpath separator. The expected application output is hello-from-published-artifact.

10. Challenge: choose the control, not the memorized command

Your CI built and tested 1.0.0. A later release job receives the already-built JAR and needs to publish exactly those bytes. Should it run mvn deploy and rebuild the project, or should it promote/deploy the captured artifact with preserved identity? Explain the risk before revealing the answer.

Preferred reasoning: if the release process is explicitly “build once, promote the same artifact,” do not silently rebuild different bytes. Use a repository/promotion mechanism or a carefully controlled artifact-deployment operation that publishes the captured bytes and POM/metadata. The exact platform mechanism belongs to the repository/CI system, but the invariant is Maven-independent: promotion should preserve artifact identity.

11. Cleanup only disposable state

cd ..
rm -rf maven-publish-lab

Knowledge check

Why does the package lab inspect a coordinate path instead of asserting the whole local repository is empty?

Why bind source/Javadoc attachments before install?

What does cmp prove in the file-repository lab?

Why is the Ed25519 lab signature called synthetic?

Why must the consumer use a fresh local repository?

12. Bridge to configuration tradeoffs

You now have observable publication state. Lesson 3 asks which of these mechanisms should be used in a real team: reactor vs install, snapshot vs final release, POM-owned distribution management vs CI-injected destinations, and developer signing vs a controlled release job.

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.

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.