Semantic Versioning in Java: Implementation and Automation

Semantic Versioning (SemVer) is a versioning scheme that provides meaning to version numbers, helping communicate the nature of changes between releases. This guide covers implementing SemVer parsing, validation, and automation in Java applications.


Core Concepts

Semantic Versioning Format:

MAJOR.MINOR.PATCH[-PRERELEASE][+BUILD]

Version Components:

  • MAJOR: Incompatible API changes
  • MINOR: Backward-compatible new functionality
  • PATCH: Backward-compatible bug fixes
  • PRERELEASE: Pre-release versions (alpha, beta, rc)
  • BUILD: Build metadata (ignored in version comparisons)

Dependencies and Setup

Maven Dependencies
<properties>
<spring-boot.version>3.1.0</spring-boot.version>
<maven-plugin-api.version>3.9.5</maven-plugin-api.version>
</properties>
<dependencies>
<!-- Spring Boot (for web applications) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<version>${spring-boot.version}</version>
<optional>true</optional>
</dependency>
<!-- Maven Plugin API (for Maven integration) -->
<dependency>
<groupId>org.apache.maven</groupId>
<artifactId>maven-plugin-api</artifactId>
<version>${maven-plugin-api.version}</version>
<optional>true</optional>
</dependency>
<!-- Testing -->
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.9.2</version>
<scope>test</scope>
</dependency>
</dependencies>

Core Implementation

1. Semantic Version Model
public class SemanticVersion implements Comparable<SemanticVersion> {
private final int major;
private final int minor;
private final int patch;
private final String preRelease;
private final String buildMetadata;
private static final Pattern SEMVER_PATTERN = Pattern.compile(
"^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)" +
"(?:-((?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\\.(?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?" +
"(?:\\+([0-9a-zA-Z-]+(?:\\.[0-9a-zA-Z-]+)*))?$"
);
public SemanticVersion(int major, int minor, int patch) {
this(major, minor, patch, null, null);
}
public SemanticVersion(int major, int minor, int patch, String preRelease, String buildMetadata) {
if (major < 0 || minor < 0 || patch < 0) {
throw new IllegalArgumentException("Version components cannot be negative");
}
validatePreRelease(preRelease);
validateBuildMetadata(buildMetadata);
this.major = major;
this.minor = minor;
this.patch = patch;
this.preRelease = preRelease;
this.buildMetadata = buildMetadata;
}
public static SemanticVersion of(String version) {
if (version == null || version.trim().isEmpty()) {
throw new IllegalArgumentException("Version string cannot be null or empty");
}
Matcher matcher = SEMVER_PATTERN.matcher(version.trim());
if (!matcher.matches()) {
throw new IllegalArgumentException("Invalid semantic version: " + version);
}
int major = Integer.parseInt(matcher.group(1));
int minor = Integer.parseInt(matcher.group(2));
int patch = Integer.parseInt(matcher.group(3));
String preRelease = matcher.group(4);
String buildMetadata = matcher.group(5);
return new SemanticVersion(major, minor, patch, preRelease, buildMetadata);
}
private void validatePreRelease(String preRelease) {
if (preRelease == null || preRelease.isEmpty()) {
return;
}
String[] identifiers = preRelease.split("\\.");
for (String identifier : identifiers) {
if (identifier.isEmpty()) {
throw new IllegalArgumentException("Pre-release identifier cannot be empty");
}
// Check if identifier is numeric-only
if (identifier.matches("\\d+")) {
// Numeric identifiers must not contain leading zeros
if (identifier.length() > 1 && identifier.startsWith("0")) {
throw new IllegalArgumentException("Numeric pre-release identifier cannot have leading zeros: " + identifier);
}
} else {
// Non-numeric identifiers should be alphanumeric with hyphens
if (!identifier.matches("[0-9A-Za-z-]+")) {
throw new IllegalArgumentException("Invalid pre-release identifier: " + identifier);
}
}
}
}
private void validateBuildMetadata(String buildMetadata) {
if (buildMetadata == null || buildMetadata.isEmpty()) {
return;
}
String[] identifiers = buildMetadata.split("\\.");
for (String identifier : identifiers) {
if (identifier.isEmpty()) {
throw new IllegalArgumentException("Build metadata identifier cannot be empty");
}
if (!identifier.matches("[0-9A-Za-z-]+")) {
throw new IllegalArgumentException("Invalid build metadata identifier: " + identifier);
}
}
}
// Getters
public int getMajor() { return major; }
public int getMinor() { return minor; }
public int getPatch() { return patch; }
public String getPreRelease() { return preRelease; }
public String getBuildMetadata() { return buildMetadata; }
public boolean isPreRelease() {
return preRelease != null && !preRelease.isEmpty();
}
public boolean isStable() {
return !isPreRelease();
}
public String getVersion() {
StringBuilder sb = new StringBuilder();
sb.append(major).append('.').append(minor).append('.').append(patch);
if (preRelease != null && !preRelease.isEmpty()) {
sb.append('-').append(preRelease);
}
if (buildMetadata != null && !buildMetadata.isEmpty()) {
sb.append('+').append(buildMetadata);
}
return sb.toString();
}
@Override
public String toString() {
return getVersion();
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
SemanticVersion that = (SemanticVersion) o;
return major == that.major && 
minor == that.minor && 
patch == that.patch &&
Objects.equals(preRelease, that.preRelease);
// Build metadata is ignored in equality check
}
@Override
public int hashCode() {
return Objects.hash(major, minor, patch, preRelease);
}
@Override
public int compareTo(SemanticVersion other) {
if (other == null) return 1;
// Compare major, minor, patch
int result = Integer.compare(major, other.major);
if (result != 0) return result;
result = Integer.compare(minor, other.minor);
if (result != 0) return result;
result = Integer.compare(patch, other.patch);
if (result != 0) return result;
// Pre-release versions have lower precedence
if (preRelease == null && other.preRelease == null) return 0;
if (preRelease == null) return 1; // No pre-release > has pre-release
if (other.preRelease == null) return -1; // Has pre-release < no pre-release
return comparePreRelease(preRelease, other.preRelease);
}
private int comparePreRelease(String pre1, String pre2) {
String[] identifiers1 = pre1.split("\\.");
String[] identifiers2 = pre2.split("\\.");
int minLength = Math.min(identifiers1.length, identifiers2.length);
for (int i = 0; i < minLength; i++) {
String id1 = identifiers1[i];
String id2 = identifiers2[i];
int result = compareIdentifier(id1, id2);
if (result != 0) return result;
}
// If all identifiers are equal, longer pre-release string has higher precedence
return Integer.compare(identifiers1.length, identifiers2.length);
}
private int compareIdentifier(String id1, String id2) {
boolean isNumeric1 = id1.matches("\\d+");
boolean isNumeric2 = id2.matches("\\d+");
if (isNumeric1 && isNumeric2) {
// Compare numerically
return Integer.compare(Integer.parseInt(id1), Integer.parseInt(id2));
} else if (isNumeric1) {
// Numeric identifiers have lower precedence than non-numeric
return -1;
} else if (isNumeric2) {
// Non-numeric identifiers have higher precedence than numeric
return 1;
} else {
// Compare lexically in ASCII sort order
return id1.compareTo(id2);
}
}
// Version increment methods
public SemanticVersion incrementMajor() {
return new SemanticVersion(major + 1, 0, 0);
}
public SemanticVersion incrementMajor(String preRelease) {
return new SemanticVersion(major + 1, 0, 0, preRelease, null);
}
public SemanticVersion incrementMinor() {
return new SemanticVersion(major, minor + 1, 0);
}
public SemanticVersion incrementMinor(String preRelease) {
return new SemanticVersion(major, minor + 1, 0, preRelease, null);
}
public SemanticVersion incrementPatch() {
return new SemanticVersion(major, minor, patch + 1);
}
public SemanticVersion incrementPatch(String preRelease) {
return new SemanticVersion(major, minor, patch + 1, preRelease, null);
}
public SemanticVersion withPreRelease(String preRelease) {
return new SemanticVersion(major, minor, patch, preRelease, buildMetadata);
}
public SemanticVersion withBuildMetadata(String buildMetadata) {
return new SemanticVersion(major, minor, patch, preRelease, buildMetadata);
}
public SemanticVersion withoutPreRelease() {
return new SemanticVersion(major, minor, patch, null, buildMetadata);
}
public SemanticVersion withoutBuildMetadata() {
return new SemanticVersion(major, minor, patch, preRelease, null);
}
// Version range checking
public boolean isCompatibleWith(SemanticVersion other) {
if (other == null) return false;
return this.major == other.major;
}
public boolean isNewerThan(SemanticVersion other) {
return this.compareTo(other) > 0;
}
public boolean isOlderThan(SemanticVersion other) {
return this.compareTo(other) < 0;
}
}
2. Version Range Support
public class VersionRange {
private final SemanticVersion minVersion;
private final SemanticVersion maxVersion;
private final boolean minInclusive;
private final boolean maxInclusive;
private static final Pattern RANGE_PATTERN = Pattern.compile(
"^([\\[(])\\s*([^,\\s]+)?\\s*,\\s*([^,\\s]+)?\\s*([\\])])$"
);
public VersionRange(SemanticVersion minVersion, SemanticVersion maxVersion, 
boolean minInclusive, boolean maxInclusive) {
this.minVersion = minVersion;
this.maxVersion = maxVersion;
this.minInclusive = minInclusive;
this.maxInclusive = maxInclusive;
if (minVersion != null && maxVersion != null && minVersion.compareTo(maxVersion) > 0) {
throw new IllegalArgumentException("Min version cannot be greater than max version");
}
}
public static VersionRange exact(SemanticVersion version) {
return new VersionRange(version, version, true, true);
}
public static VersionRange atLeast(SemanticVersion version) {
return new VersionRange(version, null, true, false);
}
public static VersionRange atMost(SemanticVersion version) {
return new VersionRange(null, version, false, true);
}
public static VersionRange between(SemanticVersion min, SemanticVersion max) {
return new VersionRange(min, max, true, true);
}
public static VersionRange of(String range) {
if (range == null || range.trim().isEmpty()) {
throw new IllegalArgumentException("Range string cannot be null or empty");
}
String trimmed = range.trim();
// Handle exact version
if (!trimmed.contains(",")) {
try {
SemanticVersion exact = SemanticVersion.of(trimmed);
return exact(exact);
} catch (IllegalArgumentException e) {
// Not an exact version, try parsing as range
}
}
Matcher matcher = RANGE_PATTERN.matcher(trimmed);
if (!matcher.matches()) {
throw new IllegalArgumentException("Invalid version range format: " + range);
}
String leftBracket = matcher.group(1);
String minStr = matcher.group(2);
String maxStr = matcher.group(3);
String rightBracket = matcher.group(4);
SemanticVersion min = minStr != null ? SemanticVersion.of(minStr) : null;
SemanticVersion max = maxStr != null ? SemanticVersion.of(maxStr) : null;
boolean minInclusive = "[".equals(leftBracket);
boolean maxInclusive = "]".equals(rightBracket);
return new VersionRange(min, max, minInclusive, maxInclusive);
}
public boolean contains(SemanticVersion version) {
if (version == null) return false;
boolean minOk = true;
boolean maxOk = true;
if (minVersion != null) {
int cmp = version.compareTo(minVersion);
minOk = minInclusive ? cmp >= 0 : cmp > 0;
}
if (maxVersion != null) {
int cmp = version.compareTo(maxVersion);
maxOk = maxInclusive ? cmp <= 0 : cmp < 0;
}
return minOk && maxOk;
}
public SemanticVersion getMinVersion() { return minVersion; }
public SemanticVersion getMaxVersion() { return maxVersion; }
public boolean isMinInclusive() { return minInclusive; }
public boolean isMaxInclusive() { return maxInclusive; }
@Override
public String toString() {
StringBuilder sb = new StringBuilder();
sb.append(minInclusive ? '[' : '(');
if (minVersion != null) {
sb.append(minVersion);
}
sb.append(',');
if (maxVersion != null) {
sb.append(maxVersion);
}
sb.append(maxInclusive ? ']' : ')');
return sb.toString();
}
}
3. Version Management Service
@Service
@Slf4j
public class VersionManagementService {
private final GitService gitService;
private final VersionHistoryRepository versionHistoryRepository;
public VersionManagementService(GitService gitService, 
VersionHistoryRepository versionHistoryRepository) {
this.gitService = gitService;
this.versionHistoryRepository = versionHistoryRepository;
}
public SemanticVersion determineNextVersion(VersionIncrementStrategy strategy, 
String currentVersion, 
List<CommitMessage> commitMessages) {
SemanticVersion current = SemanticVersion.of(currentVersion);
switch (strategy) {
case MAJOR:
return current.incrementMajor();
case MINOR:
return current.incrementMinor();
case PATCH:
return current.incrementPatch();
case AUTO:
return autoDetermineNextVersion(current, commitMessages);
case PRE_RELEASE:
return determinePreReleaseVersion(current, commitMessages);
default:
throw new IllegalArgumentException("Unknown strategy: " + strategy);
}
}
private SemanticVersion autoDetermineNextVersion(SemanticVersion current, 
List<CommitMessage> commitMessages) {
VersionIncrement maxIncrement = VersionIncrement.PATCH;
for (CommitMessage commit : commitMessages) {
VersionIncrement increment = analyzeCommitType(commit);
if (increment.ordinal() > maxIncrement.ordinal()) {
maxIncrement = increment;
}
}
switch (maxIncrement) {
case MAJOR:
return current.incrementMajor();
case MINOR:
return current.incrementMinor();
case PATCH:
return current.incrementPatch();
default:
return current.incrementPatch();
}
}
private SemanticVersion determinePreReleaseVersion(SemanticVersion current,
List<CommitMessage> commitMessages) {
SemanticVersion baseVersion = autoDetermineNextVersion(current, commitMessages);
// Get existing pre-release versions for this base
List<SemanticVersion> preReleases = versionHistoryRepository
.findPreReleasesForVersion(baseVersion.getMajor(), baseVersion.getMinor(), baseVersion.getPatch());
// Determine next pre-release identifier
String nextPreRelease = determineNextPreReleaseIdentifier(preReleases);
return baseVersion.withPreRelease(nextPreRelease);
}
private VersionIncrement analyzeCommitType(CommitMessage commit) {
String message = commit.getMessage().toLowerCase();
// Conventional Commits pattern matching
if (message.startsWith("feat:") || message.startsWith("feat(")) {
return VersionIncrement.MINOR;
} else if (message.startsWith("fix:") || message.startsWith("fix(")) {
return VersionIncrement.PATCH;
} else if (message.startsWith("break:") || message.startsWith("break(") ||
message.contains("breaking change")) {
return VersionIncrement.MAJOR;
} else if (message.startsWith("perf:") || message.startsWith("perf(")) {
return VersionIncrement.PATCH;
} else if (message.startsWith("refactor:") || message.startsWith("refactor(")) {
return VersionIncrement.PATCH;
} else if (message.startsWith("docs:") || message.startsWith("docs(")) {
return VersionIncrement.NONE;
} else if (message.startsWith("style:") || message.startsWith("style(")) {
return VersionIncrement.NONE;
} else if (message.startsWith("test:") || message.startsWith("test(")) {
return VersionIncrement.NONE;
} else if (message.startsWith("chore:") || message.startsWith("chore(")) {
return VersionIncrement.NONE;
}
return VersionIncrement.PATCH; // Default to patch for unknown types
}
private String determineNextPreReleaseIdentifier(List<SemanticVersion> existingPreReleases) {
if (existingPreReleases.isEmpty()) {
return "alpha.1";
}
// Find the highest numeric identifier for each pre-release type
Map<String, Integer> maxIdentifiers = new HashMap<>();
for (SemanticVersion version : existingPreReleases) {
String preRelease = version.getPreRelease();
if (preRelease != null) {
String[] parts = preRelease.split("\\.");
String type = parts[0];
if (parts.length > 1 && parts[1].matches("\\d+")) {
int number = Integer.parseInt(parts[1]);
maxIdentifiers.merge(type, number, Math::max);
}
}
}
// Use alpha as default type
String type = "alpha";
int nextNumber = maxIdentifiers.getOrDefault(type, 0) + 1;
return type + "." + nextNumber;
}
public VersionBumpResult bumpVersion(VersionBumpRequest request) {
try {
log.info("Bumping version for project: {}", request.getProjectName());
// Determine next version
SemanticVersion currentVersion = SemanticVersion.of(request.getCurrentVersion());
SemanticVersion nextVersion = determineNextVersion(
request.getStrategy(), 
request.getCurrentVersion(),
request.getCommitMessages()
);
// Update version files
List<VersionFileUpdate> fileUpdates = updateVersionFiles(
request.getProjectRoot(), 
currentVersion, 
nextVersion
);
// Create Git commit
String commitMessage = String.format("chore: release %s", nextVersion);
String commitHash = gitService.commitChanges(commitMessage);
// Create Git tag
String tagName = "v" + nextVersion;
gitService.createTag(tagName, commitHash);
// Record version history
VersionHistory history = VersionHistory.builder()
.projectName(request.getProjectName())
.version(nextVersion.toString())
.commitHash(commitHash)
.bumpStrategy(request.getStrategy())
.timestamp(Instant.now())
.build();
versionHistoryRepository.save(history);
return VersionBumpResult.success(nextVersion.toString(), fileUpdates, commitHash, tagName);
} catch (Exception e) {
log.error("Failed to bump version: {}", e.getMessage(), e);
return VersionBumpResult.failure(e.getMessage());
}
}
private List<VersionFileUpdate> updateVersionFiles(Path projectRoot, 
SemanticVersion currentVersion, 
SemanticVersion nextVersion) throws IOException {
List<VersionFileUpdate> updates = new ArrayList<>();
// Update pom.xml for Maven projects
Path pomPath = projectRoot.resolve("pom.xml");
if (Files.exists(pomPath)) {
updateMavenVersion(pomPath, currentVersion, nextVersion);
updates.add(new VersionFileUpdate("pom.xml", "Maven project version"));
}
// Update package.json for Node.js projects
Path packageJsonPath = projectRoot.resolve("package.json");
if (Files.exists(packageJsonPath)) {
updatePackageJsonVersion(packageJsonPath, currentVersion, nextVersion);
updates.add(new VersionFileUpdate("package.json", "Node.js package version"));
}
// Update build.gradle for Gradle projects
Path gradlePath = projectRoot.resolve("build.gradle");
if (Files.exists(gradlePath)) {
updateGradleVersion(gradlePath, currentVersion, nextVersion);
updates.add(new VersionFileUpdate("build.gradle", "Gradle project version"));
}
// Update version.properties for custom version files
Path versionPropertiesPath = projectRoot.resolve("version.properties");
if (Files.exists(versionPropertiesPath)) {
updatePropertiesVersion(versionPropertiesPath, currentVersion, nextVersion);
updates.add(new VersionFileUpdate("version.properties", "Application version"));
}
return updates;
}
private void updateMavenVersion(Path pomPath, SemanticVersion currentVersion, 
SemanticVersion nextVersion) throws IOException {
String content = Files.readString(pomPath);
// Update version in <version> tags
String updatedContent = content.replaceAll(
"<version>" + Pattern.quote(currentVersion.toString()) + "</version>",
"<version>" + nextVersion.toString() + "</version>"
);
Files.writeString(pomPath, updatedContent);
}
private void updatePackageJsonVersion(Path packageJsonPath, SemanticVersion currentVersion, 
SemanticVersion nextVersion) throws IOException {
String content = Files.readString(packageJsonPath);
// Update version in JSON
String updatedContent = content.replaceAll(
"\"version\":\\s*\"" + Pattern.quote(currentVersion.toString()) + "\"",
"\"version\": \"" + nextVersion.toString() + "\""
);
Files.writeString(packageJsonPath, updatedContent);
}
// Similar methods for Gradle and properties files...
public enum VersionIncrementStrategy {
MAJOR, MINOR, PATCH, AUTO, PRE_RELEASE
}
public enum VersionIncrement {
NONE, PATCH, MINOR, MAJOR
}
}
4. Data Transfer Objects
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class VersionBumpRequest {
private String projectName;
private String currentVersion;
private VersionManagementService.VersionIncrementStrategy strategy;
private Path projectRoot;
private List<CommitMessage> commitMessages;
private String branch;
private boolean createTag;
private boolean pushChanges;
}
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class VersionBumpResult {
private boolean success;
private String message;
private String newVersion;
private String commitHash;
private String tagName;
private List<VersionFileUpdate> updatedFiles;
private Instant timestamp;
public static VersionBumpResult success(String newVersion, List<VersionFileUpdate> updatedFiles,
String commitHash, String tagName) {
return VersionBumpResult.builder()
.success(true)
.message("Version bumped successfully")
.newVersion(newVersion)
.updatedFiles(updatedFiles)
.commitHash(commitHash)
.tagName(tagName)
.timestamp(Instant.now())
.build();
}
public static VersionBumpResult failure(String error) {
return VersionBumpResult.builder()
.success(false)
.message("Version bump failed: " + error)
.timestamp(Instant.now())
.build();
}
}
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class VersionFileUpdate {
private String filename;
private String description;
private boolean successful;
private String error;
public VersionFileUpdate(String filename, String description) {
this.filename = filename;
this.description = description;
this.successful = true;
}
}
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class CommitMessage {
private String hash;
private String message;
private String author;
private Instant timestamp;
private List<String> changedFiles;
}
@Entity
@Table(name = "version_history")
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class VersionHistory {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String projectName;
private String version;
private String commitHash;
@Enumerated(EnumType.STRING)
private VersionManagementService.VersionIncrementStrategy bumpStrategy;
private Instant timestamp;
private String releasedBy;
@Column(length = 1000)
private String releaseNotes;
}
5. Git Service Integration
@Service
@Slf4j
public class GitService {
public String commitChanges(String message) throws GitAPIException, IOException {
try (Git git = Git.open(Paths.get(".").toFile())) {
// Add all changes
git.add().addFilepattern(".").call();
// Commit
RevCommit commit = git.commit()
.setMessage(message)
.setAuthor("Version Bot", "[email protected]")
.call();
log.info("Created commit: {} - {}", commit.getId().getName(), message);
return commit.getId().getName();
}
}
public void createTag(String tagName, String commitHash) throws GitAPIException, IOException {
try (Git git = Git.open(Paths.get(".").toFile())) {
git.tag()
.setName(tagName)
.setObjectId(git.getRepository().resolve(commitHash))
.call();
log.info("Created tag: {}", tagName);
}
}
public void pushChanges() throws GitAPIException, IOException {
try (Git git = Git.open(Paths.get(".").toFile())) {
git.push().call();
log.info("Pushed changes to remote");
}
}
public void pushTag(String tagName) throws GitAPIException, IOException {
try (Git git = Git.open(Paths.get(".").toFile())) {
git.push().setPushTags().call();
log.info("Pushed tag to remote: {}", tagName);
}
}
public List<CommitMessage> getCommitsSince(String sinceVersion) throws GitAPIException, IOException {
try (Git git = Git.open(Paths.get(".").toFile())) {
String sinceTag = "v" + sinceVersion;
Iterable<RevCommit> commits = git.log()
.add(git.getRepository().resolve("HEAD"))
.not(git.getRepository().resolve(sinceTag))
.call();
List<CommitMessage> commitMessages = new ArrayList<>();
for (RevCommit commit : commits) {
CommitMessage msg = CommitMessage.builder()
.hash(commit.getId().getName())
.message(commit.getFullMessage())
.author(commit.getAuthorIdent().getName())
.timestamp(commit.getAuthorIdent().getWhen().toInstant())
.build();
commitMessages.add(msg);
}
return commitMessages;
}
}
}

REST API Controllers

1. Version Management API
@RestController
@RequestMapping("/api/versions")
@Slf4j
public class VersionController {
private final VersionManagementService versionService;
public VersionController(VersionManagementService versionService) {
this.versionService = versionService;
}
@PostMapping("/bump")
public ResponseEntity<VersionBumpResult> bumpVersion(@RequestBody VersionBumpRequest request) {
log.info("Received version bump request for project: {}", request.getProjectName());
try {
VersionBumpResult result = versionService.bumpVersion(request);
if (result.isSuccess()) {
return ResponseEntity.ok(result);
} else {
return ResponseEntity.badRequest().body(result);
}
} catch (Exception e) {
log.error("Version bump failed: {}", e.getMessage(), e);
return ResponseEntity.internalServerError()
.body(VersionBumpResult.failure("Internal server error: " + e.getMessage()));
}
}
@PostMapping("/validate")
public ResponseEntity<ValidationResult> validateVersion(@RequestBody VersionValidationRequest request) {
try {
SemanticVersion version = SemanticVersion.of(request.getVersion());
ValidationResult result = ValidationResult.builder()
.valid(true)
.version(version.toString())
.message("Valid semantic version")
.build();
return ResponseEntity.ok(result);
} catch (IllegalArgumentException e) {
ValidationResult result = ValidationResult.builder()
.valid(false)
.version(request.getVersion())
.message("Invalid semantic version: " + e.getMessage())
.build();
return ResponseEntity.badRequest().body(result);
}
}
@GetMapping("/compare")
public ResponseEntity<VersionComparison> compareVersions(
@RequestParam String version1, 
@RequestParam String version2) {
try {
SemanticVersion v1 = SemanticVersion.of(version1);
SemanticVersion v2 = SemanticVersion.of(version2);
int comparison = v1.compareTo(v2);
String relationship;
if (comparison < 0) {
relationship = version1 + " is older than " + version2;
} else if (comparison > 0) {
relationship = version1 + " is newer than " + version2;
} else {
relationship = version1 + " is equal to " + version2;
}
VersionComparison result = VersionComparison.builder()
.version1(version1)
.version2(version2)
.comparisonResult(comparison)
.relationship(relationship)
.compatible(v1.isCompatibleWith(v2))
.build();
return ResponseEntity.ok(result);
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(
VersionComparison.builder()
.error("Invalid version format: " + e.getMessage())
.build()
);
}
}
@GetMapping("/next")
public ResponseEntity<NextVersionResponse> suggestNextVersion(
@RequestParam String currentVersion,
@RequestParam(defaultValue = "AUTO") VersionManagementService.VersionIncrementStrategy strategy,
@RequestParam(required = false) String commitMessages) {
try {
List<CommitMessage> messages = parseCommitMessages(commitMessages);
SemanticVersion nextVersion = versionService.determineNextVersion(
strategy, currentVersion, messages);
NextVersionResponse response = NextVersionResponse.builder()
.currentVersion(currentVersion)
.suggestedVersion(nextVersion.toString())
.strategy(strategy)
.build();
return ResponseEntity.ok(response);
} catch (Exception e) {
return ResponseEntity.badRequest().body(
NextVersionResponse.builder()
.error(e.getMessage())
.build()
);
}
}
private List<CommitMessage> parseCommitMessages(String commitMessages) {
if (commitMessages == null || commitMessages.trim().isEmpty()) {
return List.of();
}
// Simple parsing - in reality, you'd parse from Git log or JSON
return Arrays.stream(commitMessages.split("\n"))
.map(msg -> CommitMessage.builder().message(msg).build())
.collect(Collectors.toList());
}
}
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
class VersionValidationRequest {
private String version;
}
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
class ValidationResult {
private boolean valid;
private String version;
private String message;
}
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
class VersionComparison {
private String version1;
private String version2;
private int comparisonResult;
private String relationship;
private Boolean compatible;
private String error;
}
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
class NextVersionResponse {
private String currentVersion;
private String suggestedVersion;
private VersionManagementService.VersionIncrementStrategy strategy;
private String error;
}
2. Version History API
@RestController
@RequestMapping("/api/version-history")
@Slf4j
public class VersionHistoryController {
private final VersionHistoryRepository historyRepository;
public VersionHistoryController(VersionHistoryRepository historyRepository) {
this.historyRepository = historyRepository;
}
@GetMapping("/{projectName}")
public ResponseEntity<List<VersionHistory>> getVersionHistory(@PathVariable String projectName) {
List<VersionHistory> history = historyRepository.findByProjectNameOrderByTimestampDesc(projectName);
return ResponseEntity.ok(history);
}
@GetMapping("/{projectName}/latest")
public ResponseEntity<VersionHistory> getLatestVersion(@PathVariable String projectName) {
return historyRepository.findTopByProjectNameOrderByTimestampDesc(projectName)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
@PostMapping("/{projectName}")
public ResponseEntity<VersionHistory> recordVersionRelease(
@PathVariable String projectName,
@RequestBody ReleaseVersionRequest request) {
VersionHistory history = VersionHistory.builder()
.projectName(projectName)
.version(request.getVersion())
.commitHash(request.getCommitHash())
.bumpStrategy(request.getStrategy())
.releasedBy(request.getReleasedBy())
.releaseNotes(request.getReleaseNotes())
.timestamp(Instant.now())
.build();
VersionHistory saved = historyRepository.save(history);
return ResponseEntity.status(HttpStatus.CREATED).body(saved);
}
}
@Data
@NoArgsConstructor
@AllArgsConstructor
class ReleaseVersionRequest {
private String version;
private String commitHash;
private VersionManagementService.VersionIncrementStrategy strategy;
private String releasedBy;
private String releaseNotes;
}

Maven Plugin Integration

1. Semantic Version Maven Plugin
@Mojo(name = "bump-version", defaultPhase = LifecyclePhase.VALIDATE)
public class VersionBumpMojo extends AbstractMojo {
@Parameter(property = "bumpStrategy", defaultValue = "AUTO")
private String bumpStrategy;
@Parameter(property = "createTag", defaultValue = "true")
private boolean createTag;
@Parameter(property = "pushChanges", defaultValue = "false")
private boolean pushChanges;
@Parameter(property = "project", required = true, readonly = true)
private MavenProject project;
@Override
public void execute() throws MojoExecutionException, MojoFailureException {
getLog().info("Bumping version using strategy: " + bumpStrategy);
try {
VersionManagementService versionService = createVersionService();
VersionBumpRequest request = VersionBumpRequest.builder()
.projectName(project.getArtifactId())
.currentVersion(project.getVersion())
.strategy(VersionManagementService.VersionIncrementStrategy.valueOf(bumpStrategy.toUpperCase()))
.projectRoot(project.getBasedir().toPath())
.createTag(createTag)
.pushChanges(pushChanges)
.build();
VersionBumpResult result = versionService.bumpVersion(request);
if (result.isSuccess()) {
getLog().info("Successfully bumped version to: " + result.getNewVersion());
// Update Maven project version
project.setVersion(result.getNewVersion());
} else {
throw new MojoFailureException("Version bump failed: " + result.getMessage());
}
} catch (Exception e) {
throw new MojoExecutionException("Failed to bump version", e);
}
}
private VersionManagementService createVersionService() {
// Create service instance with dependencies
return new VersionManagementService(new GitService(), new InMemoryVersionHistoryRepository());
}
}
2. Plugin Configuration in pom.xml
<build>
<plugins>
<plugin>
<groupId>com.example</groupId>
<artifactId>semantic-version-maven-plugin</artifactId>
<version>1.0.0</version>
<configuration>
<bumpStrategy>AUTO</bumpStrategy>
<createTag>true</createTag>
<pushChanges>false</pushChanges>
</configuration>
</plugin>
</plugins>
</build>

Testing

1. Unit Tests
class SemanticVersionTest {
@Test
void shouldParseValidSemanticVersion() {
// Given
String versionString = "1.2.3";
// When
SemanticVersion version = SemanticVersion.of(versionString);
// Then
assertThat(version.getMajor()).isEqualTo(1);
assertThat(version.getMinor()).isEqualTo(2);
assertThat(version.getPatch()).isEqualTo(3);
assertThat(version.isPreRelease()).isFalse();
assertThat(version.toString()).isEqualTo("1.2.3");
}
@Test
void shouldParseVersionWithPreRelease() {
// Given
String versionString = "1.2.3-alpha.1";
// When
SemanticVersion version = SemanticVersion.of(versionString);
// Then
assertThat(version.getMajor()).isEqualTo(1);
assertThat(version.getMinor()).isEqualTo(2);
assertThat(version.getPatch()).isEqualTo(3);
assertThat(version.getPreRelease()).isEqualTo("alpha.1");
assertThat(version.isPreRelease()).isTrue();
}
@Test
void shouldCompareVersionsCorrectly() {
// Given
SemanticVersion v1 = SemanticVersion.of("1.2.3");
SemanticVersion v2 = SemanticVersion.of("1.2.4");
SemanticVersion v3 = SemanticVersion.of("1.2.3-alpha");
// Then
assertThat(v1).isLessThan(v2);
assertThat(v3).isLessThan(v1);
assertThat(v1).isEqualTo(SemanticVersion.of("1.2.3"));
}
@Test
void shouldIncrementVersions() {
// Given
SemanticVersion version = SemanticVersion.of("1.2.3");
// When
SemanticVersion major = version.incrementMajor();
SemanticVersion minor = version.incrementMinor();
SemanticVersion patch = version.incrementPatch();
// Then
assertThat(major).isEqualTo(SemanticVersion.of("2.0.0"));
assertThat(minor).isEqualTo(SemanticVersion.of("1.3.0"));
assertThat(patch).isEqualTo(SemanticVersion.of("1.2.4"));
}
@Test
void shouldRejectInvalidVersions() {
assertThatThrownBy(() -> SemanticVersion.of("1.2"))
.isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> SemanticVersion.of("1.2.3.4"))
.isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> SemanticVersion.of("1.2.3-01"))
.isInstanceOf(IllegalArgumentException.class);
}
}
@SpringBootTest
class VersionManagementServiceTest {
@Autowired
private VersionManagementService versionService;
@Test
void shouldDetermineNextVersionBasedOnCommits() {
// Given
List<CommitMessage> commits = Arrays.asList(
CommitMessage.builder().message("feat: add new API endpoint").build(),
CommitMessage.builder().message("fix: resolve null pointer exception").build()
);
// When
SemanticVersion nextVersion = versionService.determineNextVersion(
VersionManagementService.VersionIncrementStrategy.AUTO,
"1.2.3",
commits
);
// Then - should bump minor because of feature commit
assertThat(nextVersion).isEqualTo(SemanticVersion.of("1.3.0"));
}
}

Best Practices

1. Configuration
# application.yml
version:
management:
auto-bump: true
create-tags: true
push-changes: false
default-strategy: AUTO
pre-release-types:
- alpha
- beta
- rc
commit-types:
major:
- "break:"
- "breaking change"
minor:
- "feat:"
patch:
- "fix:"
- "perf:"
- "refactor:"
git:
author:
name: "Version Bot"
email: "[email protected]"
2. Integration with CI/CD
@Component
@Slf4j
public class CiCdVersionIntegration {
private final VersionManagementService versionService;
public CiCdVersionIntegration(VersionManagementService versionService) {
this.versionService = versionService;
}
@EventListener
public void onBuildSuccess(BuildSuccessEvent event) {
if (shouldBumpVersion(event)) {
try {
VersionBumpRequest request = createBumpRequest(event);
VersionBumpResult result = versionService.bumpVersion(request);
if (result.isSuccess()) {
log.info("Automatically bumped version to: {}", result.getNewVersion());
// Trigger deployment or next pipeline stage
}
} catch (Exception e) {
log.error("Automatic version bump failed: {}", e.getMessage());
}
}
}
private boolean shouldBumpVersion(BuildSuccessEvent event) {
return event.getBranch().equals("main") || 
event.getBranch().equals("master") ||
event.getBranch().startsWith("release/");
}
private VersionBumpRequest createBumpRequest(BuildSuccessEvent event) {
return VersionBumpRequest.builder()
.projectName(event.getProjectName())
.currentVersion(event.getCurrentVersion())
.strategy(VersionManagementService.VersionIncrementStrategy.AUTO)
.commitMessages(event.getCommits())
.createTag(true)
.pushChanges(true)
.build();
}
}

Conclusion

Implementing Semantic Versioning in Java provides:

  • Standardized versioning across all projects
  • Automated version bumps based on commit messages
  • Integration with build tools (Maven, Gradle)
  • Git integration with automatic tagging
  • REST APIs for version management
  • Comprehensive validation and comparison

Key benefits include:

  1. Clear communication of change impact through version numbers
  2. Automated release process reducing human error
  3. Integration with CI/CD pipelines
  4. Historical tracking of version changes
  5. Support for pre-releases and build metadata

This implementation can be extended with additional features like:

  • Integration with artifact repositories (Nexus, Artifactory)
  • Support for multi-module projects
  • Advanced version range resolution for dependencies
  • Integration with issue tracking systems (Jira, GitHub Issues)
  • Automated changelog generation

Leave a Reply

Your email address will not be published. Required fields are marked *


Macro Nepal Helper