Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/brave-otters-versioning.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"changesets": minor
---

Add support for the `independent` version strategy, allowing sub-modules in a multi-module Maven project to be versioned independently of each other.
92 changes: 91 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,92 @@ users recognize the ways of working and feel at home.

This is it, at the moment. Stay tuned for more docs later on, thanks!

## Versioning strategies

By default every module in the Maven reactor shares one version (the **fixed** strategy) — any changeset bumps every module
to the same new version. This matches the typical Maven convention where child modules inherit `<version>` from the parent.

To opt into per-module versioning, drop a `.changeset/config.json` at the reactor root:

```json
{
"versioning": "independent",
"linked": [["module-a", "module-b"]],
"fixed": [["module-c", "module-d"]]
}
```

- **`fixed` (default)** — entire reactor bumps as one.
- **`independent`** — each Maven module tracks its own version. Optional `linked` and `fixed` arrays group some modules:
- **`linked` group**: members that have changesets bump together to the same new version; members without changesets stay where they are. The new version is the *highest current version in the group* bumped by the *highest level among changesets touching the group*.
- **`fixed` group**: every member of the group bumps together, even members without their own changesets.

A changeset's frontmatter lists modules by `artifactId`:

```
---
"module-a": minor
"module-b": patch
---

Description of the change.
```

The same `artifactId` cannot appear in more than one group; that is a config validation error.

### Independent versioning and `<version>` declarations

For `independent` (or `linked` / `fixed` sub-groups) to actually update individual submodule versions, each Maven submodule
must declare its own `<version>` in its `pom.xml`. Submodules that inherit `<version>` from the parent only bump together
with the parent.

## BOM (Bill of Materials) support

If your reactor contains a BOM — a `pom`-packaged module whose `<dependencyManagement>` pins sibling modules' versions via
`<properties>` (the Spring Boot convention) — opt in via `.changeset/config.json`:

```json
{
"versioning": "independent",
"bom": {
"module": "fortnox-spring-boot-dependencies",
"consumerParent": "fortnox-spring-boot-starter-parent"
}
}
```

Behavior:

- **The BOM auto-bumps** at the max level of any tracked module's bump (any reactor module that pins through the BOM's
`<dependencyManagement>`). Explicit changesets targeting the BOM still work — they combine with the synthesized level.
- **The BOM's `<properties>`** that pin sibling versions are rewritten on `prepare` (to the next `-SNAPSHOT`) and on
`release` (to the release version). The mapping is discovered by walking the BOM's `<dependencyManagement>`; you don't
have to name properties by any convention.
- **`consumerParent`** (optional) is the module a consumer sets as their `<parent>`. It typically has no `<version>` of its
own and inherits from the BOM via Maven parent inheritance. When set, it is *excluded* from the plan (no own bump) and is
used as the changelog header so consumers see entries named after the artifact they actually pin. Its `<parent>`
reference is updated when the BOM bumps. Validation: the artifactId must exist in the reactor, and if it declares its own
`<version>` it must match the BOM's.
- **Changelog rendering** in BOM mode collapses to a single top-level release header, with one sub-section per bumped
module (including the BOM, which gets a synthesized `Pinned version updates` block listing the new sibling versions).

### Releasing a starter without cutting a BOM release

Pass `-DskipBom=true` to `changesets:prepare` to bypass BOM behavior for that invocation. The starters bump as in plain
independent mode, the BOM's pom is left untouched (version *and* pinned properties), and the changelog falls back to the
standard per-module sections. The `bom` block in `.changeset/config.json` stays in place — `skipBom` is a per-run override,
not a config change. Use it when you want to ship a quick starter patch between full BOM releases.

## How `prepare` and `release` interact

`changesets:prepare` (aggregator goal, runs once at the reactor root) reads all changesets in `.changeset/`, computes a new
version per affected module, writes the new release versions to `.changeset/VERSIONS` (a properties file keyed by
artifactId), updates each affected submodule's pom to the next `*-SNAPSHOT`, and prepends a release block to the root
`CHANGELOG.md`.

`changesets:release` reads `.changeset/VERSIONS` and writes each module's pom to its release version. The `.changeset/VERSIONS`
file is the handoff between the two goals.

## Dependency updates
Due to the way automated dependency update bots like Dependabot and Renovate work, there is often a large influx of automated changesets that are not easy to merge into the normal changelog. They can also be the source of an unwanted amount of noise in the changelog.

Expand Down Expand Up @@ -57,4 +143,8 @@ To delegate versioning to the Release Maven Plugin, you can use the `ChangesetsV
</plugins>
```

Goals should then be invoked as `changesets:prepare release:prepare release:perform`. `changesets:release` should *not* be used.
Goals should then be invoked as `changesets:prepare release:prepare release:perform`. `changesets:release` should *not* be used.

With `useReleasePluginIntegration=true`, `changesets:prepare` writes `.changeset/VERSIONS` but does not modify any poms.
The maven-release-plugin then consults `ChangesetsVersionPolicy`, which reads `VERSIONS` and resolves the release / next
development version *per module* by `artifactId`. Modules not present in `VERSIONS` keep their current version unchanged.
223 changes: 223 additions & 0 deletions changesets-java/src/main/java/se/fortnox/changesets/BumpPlanner.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
package se.fortnox.changesets;

import org.slf4j.Logger;
import se.fortnox.changesets.ChangesetsConfig.Bom;

import java.util.ArrayList;
import java.util.Collection;
import java.util.EnumSet;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;

import static org.slf4j.LoggerFactory.getLogger;
import static se.fortnox.changesets.ChangesetsConfig.VersioningStrategy.FIXED;

/**
* Resolves which modules should be bumped to which versions for a release, given the
* full set of changesets, the current reactor state, and the configured versioning strategy.
* <p>
* Pure function: no I/O, no side effects.
*/
public class BumpPlanner {
private static final Logger LOG = getLogger(BumpPlanner.class);

public record ModuleBump(String artifactId, String currentVersion, String newVersion, List<Changeset> changesets) {
public boolean isVersionChange() {
return !currentVersion.equals(newVersion);
}
}

public static Map<String, ModuleBump> plan(
List<Changeset> changesets,
Map<String, String> reactor,
ChangesetsConfig config
) {
Set<String> known = reactor.keySet();
List<Changeset> known_changesets = filterKnownModules(changesets, known);

List<Group> groups = buildGroups(reactor, config);

Map<String, ModuleBump> result = new LinkedHashMap<>();
for (Group group : groups) {
List<Changeset> groupChangesets = changesetsForGroup(known_changesets, group);
if (groupChangesets.isEmpty()) {
continue;
}
result.putAll(planGroup(group, groupChangesets, reactor));
}

if (config.bom() != null) {
applyBomPlan(result, known_changesets, reactor, config.bom());
}

return result;
}

/**
* Applies BOM (Bill of Materials) semantics on top of the base plan:
* <ul>
* <li>Removes the consumer-parent from the plan (it inherits its version from the BOM).</li>
* <li>Synthesizes/merges a BOM bump at the max level of any tracked module bump,
* combined with any explicit BOM-targeted changesets.</li>
* <li>The BOM bump's {@code changesets} list contains only the explicit changesets
* targeting the BOM, so changelog rendering shows them naturally; the synthesized
* part is purely a version-bump signal.</li>
* </ul>
*/
private static void applyBomPlan(
Map<String, ModuleBump> result,
List<Changeset> allChangesets,
Map<String, String> reactor,
Bom bom
) {
String bomModule = bom.module();
String consumerParent = bom.consumerParent();

if (consumerParent != null) {
result.remove(consumerParent);
}

EnumSet<Level> trackedLevels = EnumSet.noneOf(Level.class);
for (ModuleBump bump : result.values()) {
if (bump.artifactId().equals(bomModule)) {
continue;
}
if (!bump.isVersionChange()) {
continue;
}
for (Changeset c : bump.changesets()) {
trackedLevels.add(c.level());
}
}

List<Changeset> bomExplicit = allChangesets.stream()
.filter(c -> c.packageName().equals(bomModule))
.toList();

if (trackedLevels.isEmpty() && bomExplicit.isEmpty()) {
return;
}

List<Changeset> combinedForVersionCalc = new ArrayList<>(bomExplicit);
for (Level level : trackedLevels) {
combinedForVersionCalc.add(new Changeset(bomModule, level, "", null));
}

String bomCurrent = reactor.get(bomModule);
if (bomCurrent == null) {
throw new IllegalArgumentException(
"bom.module '" + bomModule + "' is not present in the reactor");
}
String bomNew = VersionCalculator.getNewVersion(bomCurrent, combinedForVersionCalc);

result.put(bomModule, new ModuleBump(bomModule, bomCurrent, bomNew, bomExplicit));
}

private static List<Changeset> filterKnownModules(List<Changeset> changesets, Set<String> known) {
List<Changeset> kept = new ArrayList<>(changesets.size());
for (Changeset c : changesets) {
if (known.contains(c.packageName())) {
kept.add(c);
} else {
LOG.warn("Changeset {} references unknown module '{}', ignoring",
c.file() == null ? "<unknown>" : c.file().getName(),
c.packageName());
}
}
return kept;
}

private static List<Group> buildGroups(Map<String, String> reactor, ChangesetsConfig config) {
if (config.versioning() == FIXED) {
return List.of(new Group(GroupKind.FIXED, new LinkedHashSet<>(reactor.keySet())));
}

List<Group> groups = new ArrayList<>();
Set<String> assigned = new HashSet<>();
for (List<String> g : config.fixed()) {
groups.add(new Group(GroupKind.FIXED, new LinkedHashSet<>(g)));
assigned.addAll(g);
}
for (List<String> g : config.linked()) {
groups.add(new Group(GroupKind.LINKED, new LinkedHashSet<>(g)));
assigned.addAll(g);
}
for (String artifactId : reactor.keySet()) {
if (!assigned.contains(artifactId)) {
groups.add(new Group(GroupKind.INDIVIDUAL, new LinkedHashSet<>(List.of(artifactId))));
}
}
return groups;
}

private static List<Changeset> changesetsForGroup(List<Changeset> changesets, Group group) {
return changesets.stream()
.filter(c -> group.members().contains(c.packageName()))
.toList();
}

private static Map<String, ModuleBump> planGroup(Group group, List<Changeset> groupChangesets, Map<String, String> reactor) {
String baseVersion = highestVersion(group.members().stream()
.map(reactor::get)
.filter(java.util.Objects::nonNull)
.toList());
String newVersion = VersionCalculator.getNewVersion(baseVersion, groupChangesets);

Set<String> activeMembers = groupChangesets.stream()
.map(Changeset::packageName)
.collect(Collectors.toCollection(LinkedHashSet::new));

// Iterate in declaration order so changelog output is stable regardless of changeset file order
LinkedHashSet<String> bumpMembers = new LinkedHashSet<>();
for (String member : group.members()) {
boolean include = switch (group.kind()) {
case FIXED, INDIVIDUAL -> true;
case LINKED -> activeMembers.contains(member);
};
if (include) {
bumpMembers.add(member);
}
}

Map<String, ModuleBump> result = new LinkedHashMap<>();
for (String member : bumpMembers) {
String currentVersion = reactor.get(member);
if (currentVersion == null) {
continue;
}
List<Changeset> memberChangesets = groupChangesets.stream()
.filter(c -> c.packageName().equals(member))
.toList();
result.put(member, new ModuleBump(member, currentVersion, newVersion, memberChangesets));
}
return result;
}

private static String highestVersion(Collection<String> versions) {
String maxRaw = null;
org.semver4j.Semver maxSemver = null;
for (String v : versions) {
org.semver4j.Semver semver = Optional.ofNullable(org.semver4j.Semver.coerce(v))
.map(org.semver4j.Semver::withClearedBuild)
.orElseThrow(() -> new IllegalArgumentException("Cannot coerce \"%s\" into a semantic version.".formatted(v)));
if (maxSemver == null || semver.compareTo(maxSemver) > 0) {
maxSemver = semver;
maxRaw = v;
}
}
if (maxRaw == null) {
throw new IllegalStateException("Group has no resolvable members in reactor");
}
return maxRaw;
}

private enum GroupKind { FIXED, LINKED, INDIVIDUAL }

private record Group(GroupKind kind, Set<String> members) {}
}
Loading
Loading