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:
- What is the version string in
pom.xmlon the tagv0.1.0? What is it onmainnow? - What does
release.propertiescontain? Why does Maven save this? - What happens if you run
mvn release:prepareagain 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:preparemake two commits? The first commit sets the version to0.1.0(no SNAPSHOT suffix) — this is what gets tagged. The second commit bumps to0.2.0-SNAPSHOT— this is what developers continue working from. - What is
release.properties? It stores the state of an in-progress release sorelease:perform(andrelease:rollback) know what to do. If you lose this file mid-release, you have torelease:rollbackand 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);
}
}
}