Lab 01 — Release Mechanics

Phase: 04 — ASF Governance and Release Engineering
Difficulty: ⭐⭐⭐☆☆ | Estimated Time: 2–3 hours
Primary Artifact: Working mvn release:prepare run with Git tag
Verify: git log --oneline --decorate shows a v0.1.0 tag; pom.xml version is 0.2.0-SNAPSHOT


Goal

Run the full Maven Release Plugin workflow against a real local Git repository. By the end you will understand exactly what mvn release:prepare does, what commits it makes, what it tags, and why each step matters for a reproducible, traceable release.

The project ships with a small Java library (Calculator) and JUnit 5 tests that already pass. Your job is to wire up the release infrastructure and execute the release.


Setup

# 1. Enter the lab directory
cd phase-04-asf-governance-release/lab-01-release-mechanics

# 2. Initialize a local bare Git repository to act as the "remote"
#    (This simulates dist.apache.org or a GitHub remote without needing network access)
git init --bare ../release-remote.git

# 3. Initialize the lab project as a Git repo and push to the fake remote
git init
git add .
git commit -m "initial: add Calculator library"
git remote add origin ../release-remote.git
git push -u origin main

# 4. Verify the tests pass before touching the release plugin
mvn test

Expected output from Step 4:

[INFO] Tests run: 5, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS

Steps

Step 1 — Dry Run (read the plan before executing)

mvn release:prepare -DdryRun=true

Answer the prompts:

  • What version do you want to release? → 0.1.0
  • What is the SCM release tag? → v0.1.0
  • What is the next development version? → 0.2.0-SNAPSHOT

After the dry run, inspect:

# What pom.xml would look like after release:prepare
cat pom.xml.tag           # the to-be-tagged version
cat pom.xml.next          # the next snapshot version
cat release.properties    # everything release:prepare recorded

Answer before continuing: What 3 commits would release:prepare make to Git?

# Clean up dry run artifacts before the real run
mvn release:clean

Step 2 — Execute the Release

mvn release:prepare

Use the same version answers as Step 1. Watch what happens. After it completes:

# Inspect the Git log
git log --oneline --decorate

# Inspect the tag
git show v0.1.0

# Inspect what changed between the two release commits
git diff HEAD~2 HEAD~1    # the "prepare release" commit
git diff HEAD~1 HEAD      # the "prepare for next development" commit

Questions to answer:

  1. What is the version string in pom.xml on the tag v0.1.0? What is it on main now?
  2. What does release.properties contain? Why does Maven save this?
  3. What happens if you run mvn release:prepare again on the same version? Try it with -DdryRun=true.

Step 3 — Simulate release:perform

release:perform checks out the tagged commit, builds it, and deploys the artifacts. Since we don't have a staging repository, we'll simulate just the build step:

# Check out the tagged commit in a temp directory
git clone --branch v0.1.0 ../release-remote.git /tmp/release-check
cd /tmp/release-check

# Build and verify the release artifacts
mvn verify

# What SHA-256 is the produced JAR?
shasum -a 256 target/release-lab-0.1.0.jar      # macOS
sha256sum target/release-lab-0.1.0.jar           # Linux

Record the SHA-256. You will sign this artifact in Lab 02.

Step 4 — Write the Release Checklist

In release-checklist.md (create it), document the complete release steps you just performed as a numbered checklist with checkboxes. Format it as if you were writing the release runbook for a future release manager who has never done this before.

The checklist must include: branch cut, version bump, tag, build, artifact verification, GPG signing (placeholder), dist upload (placeholder), vote (placeholder), announcement (placeholder).


Expected Output

lab-01-release-mechanics/
  pom.xml
  src/
  release-checklist.md        ← your created checklist

And in Git:

$ git log --oneline --decorate
abc1234 (HEAD -> main) [maven-release-plugin] prepare for next development iteration
def5678 (tag: v0.1.0) [maven-release-plugin] prepare release v0.1.0
ghi9012 initial: add Calculator library

Verification

# Tag exists
git tag | grep v0.1.0

# Version on tag is 0.1.0 (not SNAPSHOT)
git show v0.1.0:pom.xml | grep '<version>'

# Version on main is next SNAPSHOT
grep '<version>' pom.xml | head -1

# Tests pass on the tagged commit
git stash
git checkout v0.1.0
mvn test
git checkout main

All four checks must pass.


Talking Points

  • Why tag a release in Git? The tag is the only way to reproducibly check out exactly the code that was shipped. Without it, "the release build" means nothing — any commit could have been used.
  • Why does release:prepare make two commits? The first commit sets the version to 0.1.0 (no SNAPSHOT suffix) — this is what gets tagged. The second commit bumps to 0.2.0-SNAPSHOT — this is what developers continue working from.
  • What is release.properties? It stores the state of an in-progress release so release:perform (and release:rollback) know what to do. If you lose this file mid-release, you have to release:rollback and start over.

Source Files

pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<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>org.example.release</groupId>
    <artifactId>release-lab</artifactId>
    <!-- maven-release-plugin will rewrite this version during release:prepare -->
    <version>0.1.0-SNAPSHOT</version>
    <packaging>jar</packaging>

    <name>Release Lab — Calculator</name>
    <description>
    A minimal Maven library used to practice the Maven Release Plugin workflow.
    The Calculator class provides basic arithmetic operations.
  </description>

    <!--
    SCM section: Required by maven-release-plugin.

    For this lab, we point to a local bare git repo you create with:
      git init --bare ../release-remote.git

    The ${project.basedir} resolves to the directory containing this pom.xml,
    so ../release-remote.git is one level up from the lab directory.

    In a real Apache project this would be:
      scm:git:https://gitbox.apache.org/repos/asf/[project].git
  -->
    <scm>
        <connection>scm:git:file://${project.basedir}/../release-remote.git</connection>
        <developerConnection>scm:git:file://${project.basedir}/../release-remote.git</developerConnection>
        <tag>HEAD</tag>
    </scm>

    <properties>
        <maven.compiler.source>11</maven.compiler.source>
        <maven.compiler.target>11</maven.compiler.target>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <!-- Pins the exact plugin versions so the release is reproducible -->
        <maven-release-plugin.version>3.0.1</maven-release-plugin.version>
        <maven-surefire-plugin.version>3.2.5</maven-surefire-plugin.version>
        <junit.version>5.10.2</junit.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.junit.jupiter</groupId>
            <artifactId>junit-jupiter</artifactId>
            <version>${junit.version}</version>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <!--
        maven-surefire-plugin: runs JUnit 5 tests.
        Version 3.x supports JUnit Platform natively (no extra providers needed).
      -->
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-surefire-plugin</artifactId>
                <version>${maven-surefire-plugin.version}</version>
            </plugin>

            <!--
        maven-release-plugin: automates the release workflow.

        Key lifecycle:
          mvn release:prepare  — version bump, tag, push
          mvn release:perform  — checkout tag, build, deploy
          mvn release:rollback — undo a prepare (if something went wrong)
          mvn release:clean    — remove release.properties and *.backup files

        tagNameFormat: The format of the Git tag.
          @{project.version} resolves to the version being released (e.g., 0.1.0)
          We prefix with "v" (v0.1.0) — standard for most Apache projects.

        preparationGoals: Goals run during release:prepare (default: install).
          We run verify to ensure tests pass before the tag is created.

        autoVersionSubmodules: For multi-module projects, version all modules
          together. Setting to true prevents version drift between sibling modules.
      -->
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-release-plugin</artifactId>
                <version>${maven-release-plugin.version}</version>
                <configuration>
                    <tagNameFormat>v@{project.version}</tagNameFormat>
                    <autoVersionSubmodules>true</autoVersionSubmodules>
                    <preparationGoals>verify</preparationGoals>
                </configuration>
            </plugin>
        </plugins>
    </build>

    <!--
    distributionManagement: Where mvn release:perform deploys artifacts.
    For this lab we use a local file-system repo so no network access is needed.
    Replace these paths with your Apache Nexus staging repository in a real project.
  -->
    <distributionManagement>
        <repository>
            <id>local-release</id>
            <url>file://${project.basedir}/../local-maven-repo</url>
        </repository>
        <snapshotRepository>
            <id>local-snapshots</id>
            <url>file://${project.basedir}/../local-maven-repo-snapshots</url>
        </snapshotRepository>
    </distributionManagement>

</project>

Calculator.java

package org.example.release;

/**
 * A basic four-function calculator.
 *
 * <p>
 * This class is the release artifact for Lab 01. Its purpose is to give the
 * Maven Release
 * Plugin something real to build, version, and tag — not to demonstrate complex
 * logic.
 *
 * <p>
 * Version history (maintained as release notes in this comment block):
 * <ul>
 * <li>0.1.0 — Initial release: add, subtract, multiply, divide</li>
 * </ul>
 */
public class Calculator {

    /**
     * Returns the sum of {@code a} and {@code b}.
     */
    public int add(int a, int b) {
        return a + b;
    }

    /**
     * Returns the difference {@code a - b}.
     */
    public int subtract(int a, int b) {
        return a - b;
    }

    /**
     * Returns the product of {@code a} and {@code b}.
     */
    public int multiply(int a, int b) {
        return a * b;
    }

    /**
     * Returns the integer quotient of {@code a / b}.
     *
     * @throws ArithmeticException if {@code b} is zero — not silently swallowed,
     *                             because hiding errors in a library violates the
     *                             principle of least surprise.
     *                             Callers that want a default value must catch this
     *                             explicitly.
     */
    public int divide(int a, int b) {
        if (b == 0) {
            throw new ArithmeticException("Division by zero: cannot divide " + a + " by 0");
        }
        return a / b;
    }
}

CalculatorTest.java

package org.example.release;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;

class CalculatorTest {

    private Calculator calc;

    @BeforeEach
    void setUp() {
        calc = new Calculator();
    }

    // -------------------------------------------------------------------------
    // Add
    // -------------------------------------------------------------------------

    @Test
    void add_positives() {
        assertEquals(5, calc.add(2, 3));
    }

    @Test
    void add_negativeAndPositive() {
        assertEquals(-1, calc.add(-4, 3));
    }

    @Test
    void add_zeros() {
        assertEquals(0, calc.add(0, 0));
    }

    // -------------------------------------------------------------------------
    // Subtract
    // -------------------------------------------------------------------------

    @Test
    void subtract_largerFromSmaller() {
        assertEquals(-1, calc.subtract(2, 3));
    }

    @Test
    void subtract_sameValue() {
        assertEquals(0, calc.subtract(7, 7));
    }

    // -------------------------------------------------------------------------
    // Multiply
    // -------------------------------------------------------------------------

    @ParameterizedTest(name = "{0} × {1} = {2}")
    @CsvSource({
            "2,  3,  6",
            "0,  9,  0",
            "-2, 3, -6",
            "-2,-3,  6"
    })
    void multiply_tableOfValues(int a, int b, int expected) {
        assertEquals(expected, calc.multiply(a, b));
    }

    // -------------------------------------------------------------------------
    // Divide
    // -------------------------------------------------------------------------

    @Test
    void divide_evenDivision() {
        assertEquals(3, calc.divide(9, 3));
    }

    @Test
    void divide_integerTruncation() {
        // Integer division truncates toward zero — important to document for callers
        assertEquals(2, calc.divide(7, 3));
    }

    @Test
    void divide_byZero_throwsArithmeticException() {
        ArithmeticException ex = assertThrows(
                ArithmeticException.class,
                () -> calc.divide(1, 0));

        // The message must mention "zero" — callers deserve a readable error
        assertTrue_messageContains(ex.getMessage(), "zero");
    }

    // -------------------------------------------------------------------------
    // Private helper — avoid importing Hamcrest just for one assertion
    // -------------------------------------------------------------------------

    private static void assertTrue_messageContains(String message, String substring) {
        if (!message.toLowerCase().contains(substring.toLowerCase())) {
            throw new AssertionError(
                    "Expected message to contain '" + substring + "' but was: " + message);
        }
    }
}