Semantic Versioning done automatically

Software evolves over time. Bugs are fixed, features are added, and occasionally design mistakes are corrected in ways that are incompatible with earlier releases. Version numbers exist to identify these distinct states of a software artifact and to allow consumers to reason about whether an upgrade is safe. Without versioning, it is impossible to distinguish between two builds of the same library or to express expectations about their compatibility.

Semantic versioning

Software versions come in many forms, ranging from simple incrementing numbers to schemes based on release dates. One of the most widely adopted approaches is Semantic Versioning, commonly abbreviated as SemVer. SemVer defines a structured MAJOR.MINOR.PATCH version format with explicit rules about how version numbers must change in response to different kinds of software changes.

History

Before SemVer, version numbers were largely arbitrary. Consumers could not reliably infer whether upgrading from one version to another was safe or would break their builds”. A change from 1.2 to 1.3 might be a small bugfix, or it might silently remove an API your application depended on. The only way to know was to read release notes, scan commit logs, or try the upgrade and see if anything failed.

As dependency graphs grew larger in the late 2000s, this uncertainty became a scaling problem. Package managers were becoming more common, applications depended on dozens of libraries, and automated upgrades were increasingly desirable but risky.

Semantic Versioning was introduced around 2009–2010 by Tom Preston-Werner, one of the founders of GitHub, as a formal specification to address this. The goal was simple: use version numbers to clearly communicate compatibility guarantees, so both humans and tools could make safer decisions about upgrades.

SemVer set out to solve the core problem of ambiguity by making the impact of a release explicit, without requiring every consumer to read documentation or reverse-engineer intent from changes.

Problem

While the SemVer specification defines clear and well-documented rules, it provides no mechanism to enforce them. In practice, adherence to SemVer relies almost entirely on developer discipline, common sense, and code reviews. As projects grow and teams change, this manual enforcement becomes inconsistent and error-prone.

This lack of enforcement leads to a familiar situation: version numbers claim semantic meaning, but consumers cannot fully trust them. A supposedly non-breaking release may still introduce incompatible API changes, undermining the very guarantees SemVer was designed to provide.

Solution

To address this problem, I created a SemVer checker as a Maven plugin. The goal was straightforward: automatically verify that version changes align with actual API changes, making semantic versioning enforceable. The plugin analyzes bytecode, compares APIs using reflection, and flags any mismatch between the declared version and the actual changes. There is no Gradle plugin available yet, but as the project is open source, contributions are welcome.

Rules

The SemVer specification consists of a set of rules that define how version numbers must change based on the nature of code changes. The most relevant rules from a consumer and compatibility perspective are listed below, along with how they are enforced by the SemVer plugin.

Many rules in the specification concern the structure of version strings themselves, such as pre-release identifiers and build metadata. These are outside the scope of the plugin. For the complete and authoritative list of rules, refer to the official SemVer specification at https://semver.org/ 

Software using Semantic Versioning MUST declare a public API. This API could be declared in the code itself or exist strictly in documentation.

https://semver.org/#spec-item-1

From an automation perspective, this is already part of Java. Public and protected classes, methods, and fields make a machine-readable public API. Beyond that, anything packaged into a JAR, configuration files or SPI descriptors for example, can also be considered part of the API, whether intended or not.

Major version MUST be incremented if any backward incompatible changes are introduced to the public API. It MAY also include minor and patch level changes.

https://semver.org/#spec-item-8

To enforce this rule automatically, we need to identify which changes are considered breaking and how they can be detected.

One class of backward-incompatible changes is the removal of files from the published JAR. While some files may be irrelevant, such as documentation, others may be required at runtime, for example, configuration files or service descriptors. Since the actual usage cannot be determined reliably, the plugin treats any removed file as incompatible. This can be implemented by comparing the contents of the previous and current artifacts and flagging any missing entries.

Another category consists of changes to the public API itself. Removing a public or protected class, method, or field clearly breaks compatibility, as consumers relying on those elements will no longer be able to compile or run their code. To detect these changes, the public API of the previously released version is compared against the current one using reflection. Both versions are loaded with separate class loaders to avoid class name collisions. Without this isolation, the JVM would reject loading version 2.0.0 of com.example.MyClass if version 1.0.0 is already loaded. The JVM enforces that each fully qualified class name can only be loaded once per class loader. By using separate class loaders, the plugin can load both versions simultaneously and compare them side by side.

Method signature changes deserve special attention. Changing parameter types or return types is generally a breaking change, but there are exceptions. Consider the following example:

// Before 
public void doStuff() { } 
// After 
public String doStuff() { return null; }

If the return type of doStuff is modified, this is usually incompatible. However, if the original method returned void and is later changed to return a value, existing consumers can continue calling the method without modification and simply ignore the return value. In this case, the change can be classified as minor rather than major.

Finally, increasing the required Java version is also a backward-incompatible change. If the library is compiled for a newer Java release, all consumers must upgrade their runtime before they can use it. This can be detected by inspecting the class file version. Each class file starts with the magic number 0xCAFEBABE, followed by the minor and major version numbers. By reading the major version and mapping it to a Java release (for example, version 52 for Java 8 and 69 for Java 25), the plugin can determine whether the required Java version has increased.

Minor version MUST be incremented if new, backward-compatible functionality is introduced to the public API. It MUST be incremented if any public API functionality is marked as deprecated.

https://semver.org/#spec-item-7

The most straightforward example of a minor change is the addition of new public or protected API elements. This includes new classes, methods, or fields. While scanning for removed API elements to detect breaking changes, the plugin can simultaneously look for additions. If a new public or protected element is present in the current version but not in the previous one, the change is classified as minor, as it represents new functionality that consumers may choose to use.

Another case is a change from a void return type to a non-void return type. As discussed earlier, this change does not break existing consumers. Calls to the method remain valid, and consumers who are interested in the new behavior can start using the return value. As a result, this change is classified as a minor version increment.

Annotations require more nuanced handling. Marking a public API element as deprecated using the @Deprecated annotation is explicitly covered by the Semantic Versioning specification and must result in a minor version bump. This can be detected reliably through reflection.

Other annotation changes are more ambiguous. Adding an annotation often introduces new behavior, but it may also be purely informational. Removing or changing an annotation can fix a bug, add functionality, or, in some cases, introduce backward-incompatible behavior. Since the semantic meaning of an annotation cannot be determined automatically, the plugin cannot reliably classify these changes. For this reason, annotation additions and removals are left configurable. The default behavior of the plugin is to classify such changes as patch-level, but developers can override this if their use case requires stricter handling.

Patch version MUST be incremented if only backward compatible bug fixes are introduced. A bug fix is defined as an internal change that fixes incorrect behavior.

https://semver.org/#spec-item-6

From an automation perspective, patch changes are detected implicitly. If no major or minor changes have been identified, but the compiled output differs from the previous release, the change is classified as a patch. A practical way to detect this is by comparing the checksums of the class files. If the checksum differs, some internal implementation detail must have changed, even though the public API remains the same.

This approach works well in most cases, but it comes with important caveats. Code generators such as MapStruct or Lombok often add metadata to the generated bytecode, for example, a @Generated annotation containing a timestamp. Even if the generated code is functionally identical, this metadata causes the class file to differ, resulting in a different checksum and a patch classification.

Similarly, compiling the same source code with a different compiler version, or even with different compiler settings, can produce bytecode with a different layout. Again, the behavior of the code may be unchanged, but the checksum comparison will still detect a difference.

For this reason, checksum comparison is used as a pragmatic fallback: once major and minor changes have been ruled out, any remaining bytecode differences are classified as patch-level changes.

Dependencies

Most libraries have dependencies, and many have transitive dependencies as well. The SemVer specification does not provide clear guidance on how dependency changes should be versioned. The plugin currently treats all dependency changes as patch-level modifications. 

This approach is based on the assumption that consumers follow the best practice of explicitly declaring all dependencies they directly use in their own projects. Under this model, if a library removes or changes a dependency, well-structured consumer projects should remain unaffected, as they declare their own requirements independently. 

However, this assumption does not always hold in practice. If a consumer relies on a transitive dependency without declaring it explicitly (a form of implicit coupling) then removing that dependency could break their build. More problematic still is the reality of dependency version conflicts in the JVM ecosystem. If your library depends on Jackson 2.14 and a consumer uses Jackson 2.12, runtime failures can occur even when both projects follow best practices. The JVM’s flat classpath means only one version of a dependency can be present at runtime, and resolution strategies don’t always choose the compatible one. 

Similarly, upgrading a dependency to a version with breaking changes could introduce incompatibilities, even if the library’s own API remains unchanged. A consumer might compile successfully but encounter NoSuchMethodError or ClassNotFoundException at runtime due to version mismatches.

Incorporate in your project

There are multiple ways to incorporate the SemVer checker into a project. The simplest approach is to run it manually after creating your first release. You can check semantic versioning compliance by running:

mvn io.github.jagodevreede:semver-check-maven-plugin:check

With the default setting this will output something like:

Class io.github.jagodevreede.semver.example has been changed on byte level
Determined SemVer type as patch and is currently none, next version should be: 1.0.3

Additionally, a file named nextVersion.txt is generated in the target directory containing the suggested next version number.

If you want more control then you can also add the plugin to your maven POM, for example with: 

<build>
    ...
    <plugins>
        ...
        <plugin>
            <artifactId>semver-check-maven-plugin</artifactId>
            <groupId>io.github.jagodevreede</groupId>
            <version>VERSION_NUMBER</version>
            <configuration>
                <haltOnFailure>true</haltOnFailure>
                <outputFileName>nextVersion.txt</outputFileName>
            </configuration>
            <executions>
                <execution>
                    <id>check</id>
                    <phase>verify</phase>
                    <goals>
                        <goal>check</goal>
                    </goals>
                </execution>
            </executions>
        </plugin>
        ...
    </plugins>
    ...
</build>

With this configuration, the plugin runs during the verify phase and fails the build if the version number defined in the POM does not match the version required by Semantic Versioning. This makes versioning rules enforceable as part of the build process.

For a full list of configuration options, consult the README.md in the project repository at https://github.com/jagodevreede/semver-check

Conclusion 

Semantic Versioning promises clarity in dependency management, but that promise only holds if version numbers accurately reflect the changes they contain. Manual enforcement of these rules has repeatedly proven to be fragile.

By automating SemVer compliance checking, this plugin transforms versioning from a discipline problem into a build-time guarantee. Integration is straightforward as shown above. The few seconds it adds to your build time are outweighed by the potential reduction in frustration for downstream consumers later on. 

The project is open source and available at https://github.com/jagodevreede/semver-check. Contributions and bug reports are welcome. If you’ve ever released a major change disguised as a patch, this tool is for you. It’s designed to prevent it from happening again.

References

https://semver.org

Interested in Learning More?
Jago de Vreede is a speaker at JCON!
This article explores how to make semantic versioning reliable in modern build pipelines – and his JCON session complements this with real-world insights into building and evolving enterprise applications with Quarkus.
If you can’t attend live, the session video will be available after the conference – it’s worth checking out!

Total
0
Shares
Previous Post

curl | bash | hacked: the unseen dangers in your dev lifecycle

Next Post

03-2026 | From Coder to System Designer

Related Posts