From 38cf53baed4b20265ed298d50562b54ad92753a4 Mon Sep 17 00:00:00 2001 From: Andreas Rilling Date: Tue, 23 Jun 2026 15:27:34 +0200 Subject: [PATCH 1/5] feat: support 'individual' version strategy to manage sub modules seperate --- README.md | 55 ++++- .../se/fortnox/changesets/BumpPlanner.java | 154 +++++++++++++ .../changesets/ChangelogAggregator.java | 105 +++++++-- .../fortnox/changesets/ChangesetLocator.java | 33 +-- .../fortnox/changesets/ChangesetsConfig.java | 103 +++++++++ .../fortnox/changesets/BumpPlannerTest.java | 202 ++++++++++++++++++ .../changesets/ChangelogAggregatorTest.java | 96 ++++++++- .../changesets/ChangesetsConfigTest.java | 134 ++++++++++++ .../src/it/fixed-groups/.changeset/bump-a.md | 5 + .../it/fixed-groups/.changeset/config.json | 4 + .../src/it/fixed-groups/EXPECTED_CHANGELOG.md | 8 + .../invoker.properties | 2 +- .../src/it/fixed-groups/module-a/pom.xml | 12 ++ .../src/it/fixed-groups/module-b/pom.xml | 12 ++ .../src/it/fixed-groups/module-c/pom.xml | 12 ++ .../src/it/fixed-groups/pom.xml | 21 ++ .../src/it/fixed-groups/verify.groovy | 26 +++ .../.changeset/bump-a.md | 5 + .../.changeset/bump-b.md | 5 + .../.changeset/config.json | 3 + .../EXPECTED_CHANGELOG.md | 14 ++ .../independent-versioning/invoker.properties | 1 + .../independent-versioning/module-a/pom.xml | 12 ++ .../independent-versioning/module-b/pom.xml | 12 ++ .../src/it/independent-versioning/pom.xml | 20 ++ .../it/independent-versioning/verify.groovy | 25 +++ .../.changeset/config.json | 4 + .../linked-groups-multi/.changeset/minor-b.md | 5 + .../linked-groups-multi/.changeset/patch-a.md | 5 + .../linked-groups-multi/EXPECTED_CHANGELOG.md | 14 ++ .../it/linked-groups-multi/invoker.properties | 1 + .../it/linked-groups-multi/module-a/pom.xml | 12 ++ .../it/linked-groups-multi/module-b/pom.xml | 12 ++ .../src/it/linked-groups-multi/pom.xml | 20 ++ .../src/it/linked-groups-multi/verify.groovy | 20 ++ .../src/it/linked-groups/.changeset/bump-a.md | 5 + .../it/linked-groups/.changeset/config.json | 4 + .../it/linked-groups/EXPECTED_CHANGELOG.md | 8 + .../src/it/linked-groups/invoker.properties | 1 + .../src/it/linked-groups/module-a/pom.xml | 12 ++ .../src/it/linked-groups/module-b/pom.xml | 12 ++ .../pom.xml | 12 +- .../src/it/linked-groups/verify.groovy | 19 ++ .../src/it/multi-module/.changeset/VERSION | 1 - .../src/it/multi-module/EXPECTED_CHANGELOG.md | 4 +- .../src/it/multi-module/verify.groovy | 21 +- .../.changeset/nine-owls-knock.md | 5 - .../.changeset/seven-owls-suspect.md | 5 - .../prepare-no-version/EXPECTED_CHANGELOG.md | 12 -- .../src/it/prepare-no-version/verify.groovy | 22 -- .../.changeset/VERSION | 1 - .../EXPECTED_CHANGELOG.md | 4 +- .../verify.groovy | 16 +- .../src/it/prepare/.changeset/VERSION | 1 - .../src/it/prepare/EXPECTED_CHANGELOG.md | 4 +- .../src/it/prepare/pom.xml | 2 +- .../src/it/prepare/verify.groovy | 13 +- .../it/release-multimodule/.changeset/VERSION | 1 - .../release-multimodule/.changeset/VERSIONS | 3 + .../src/it/release-multimodule/verify.groovy | 18 +- .../src/it/release/.changeset/VERSION | 1 - .../src/it/release/.changeset/VERSIONS | 1 + .../src/it/release/verify.groovy | 8 +- .../se/fortnox/changesets/VersionFile.java | 45 ---- .../se/fortnox/changesets/VersionsFile.java | 65 ++++++ .../fortnox/changesets/maven/PrepareMojo.java | 151 +++++++++---- .../fortnox/changesets/maven/ReleaseMojo.java | 63 ++++-- .../maven/policy/ChangesetsVersionPolicy.java | 62 ++++-- 68 files changed, 1509 insertions(+), 267 deletions(-) create mode 100644 changesets-java/src/main/java/se/fortnox/changesets/BumpPlanner.java create mode 100644 changesets-java/src/main/java/se/fortnox/changesets/ChangesetsConfig.java create mode 100644 changesets-java/src/test/java/se/fortnox/changesets/BumpPlannerTest.java create mode 100644 changesets-java/src/test/java/se/fortnox/changesets/ChangesetsConfigTest.java create mode 100644 changesets-maven-plugin/src/it/fixed-groups/.changeset/bump-a.md create mode 100644 changesets-maven-plugin/src/it/fixed-groups/.changeset/config.json create mode 100644 changesets-maven-plugin/src/it/fixed-groups/EXPECTED_CHANGELOG.md rename changesets-maven-plugin/src/it/{prepare-no-version => fixed-groups}/invoker.properties (78%) create mode 100644 changesets-maven-plugin/src/it/fixed-groups/module-a/pom.xml create mode 100644 changesets-maven-plugin/src/it/fixed-groups/module-b/pom.xml create mode 100644 changesets-maven-plugin/src/it/fixed-groups/module-c/pom.xml create mode 100644 changesets-maven-plugin/src/it/fixed-groups/pom.xml create mode 100644 changesets-maven-plugin/src/it/fixed-groups/verify.groovy create mode 100644 changesets-maven-plugin/src/it/independent-versioning/.changeset/bump-a.md create mode 100644 changesets-maven-plugin/src/it/independent-versioning/.changeset/bump-b.md create mode 100644 changesets-maven-plugin/src/it/independent-versioning/.changeset/config.json create mode 100644 changesets-maven-plugin/src/it/independent-versioning/EXPECTED_CHANGELOG.md create mode 100644 changesets-maven-plugin/src/it/independent-versioning/invoker.properties create mode 100644 changesets-maven-plugin/src/it/independent-versioning/module-a/pom.xml create mode 100644 changesets-maven-plugin/src/it/independent-versioning/module-b/pom.xml create mode 100644 changesets-maven-plugin/src/it/independent-versioning/pom.xml create mode 100644 changesets-maven-plugin/src/it/independent-versioning/verify.groovy create mode 100644 changesets-maven-plugin/src/it/linked-groups-multi/.changeset/config.json create mode 100644 changesets-maven-plugin/src/it/linked-groups-multi/.changeset/minor-b.md create mode 100644 changesets-maven-plugin/src/it/linked-groups-multi/.changeset/patch-a.md create mode 100644 changesets-maven-plugin/src/it/linked-groups-multi/EXPECTED_CHANGELOG.md create mode 100644 changesets-maven-plugin/src/it/linked-groups-multi/invoker.properties create mode 100644 changesets-maven-plugin/src/it/linked-groups-multi/module-a/pom.xml create mode 100644 changesets-maven-plugin/src/it/linked-groups-multi/module-b/pom.xml create mode 100644 changesets-maven-plugin/src/it/linked-groups-multi/pom.xml create mode 100644 changesets-maven-plugin/src/it/linked-groups-multi/verify.groovy create mode 100644 changesets-maven-plugin/src/it/linked-groups/.changeset/bump-a.md create mode 100644 changesets-maven-plugin/src/it/linked-groups/.changeset/config.json create mode 100644 changesets-maven-plugin/src/it/linked-groups/EXPECTED_CHANGELOG.md create mode 100644 changesets-maven-plugin/src/it/linked-groups/invoker.properties create mode 100644 changesets-maven-plugin/src/it/linked-groups/module-a/pom.xml create mode 100644 changesets-maven-plugin/src/it/linked-groups/module-b/pom.xml rename changesets-maven-plugin/src/it/{prepare-no-version => linked-groups}/pom.xml (63%) create mode 100644 changesets-maven-plugin/src/it/linked-groups/verify.groovy delete mode 100644 changesets-maven-plugin/src/it/multi-module/.changeset/VERSION delete mode 100644 changesets-maven-plugin/src/it/prepare-no-version/.changeset/nine-owls-knock.md delete mode 100644 changesets-maven-plugin/src/it/prepare-no-version/.changeset/seven-owls-suspect.md delete mode 100644 changesets-maven-plugin/src/it/prepare-no-version/EXPECTED_CHANGELOG.md delete mode 100644 changesets-maven-plugin/src/it/prepare-no-version/verify.groovy delete mode 100644 changesets-maven-plugin/src/it/prepare-release-plugin-integration/.changeset/VERSION delete mode 100644 changesets-maven-plugin/src/it/prepare/.changeset/VERSION delete mode 100644 changesets-maven-plugin/src/it/release-multimodule/.changeset/VERSION create mode 100644 changesets-maven-plugin/src/it/release-multimodule/.changeset/VERSIONS delete mode 100644 changesets-maven-plugin/src/it/release/.changeset/VERSION create mode 100644 changesets-maven-plugin/src/it/release/.changeset/VERSIONS delete mode 100644 changesets-maven-plugin/src/main/java/se/fortnox/changesets/VersionFile.java create mode 100644 changesets-maven-plugin/src/main/java/se/fortnox/changesets/VersionsFile.java diff --git a/README.md b/README.md index 35218fe..8630f2d 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,55 @@ 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 `` 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 `` declarations + +For `independent` (or `linked` / `fixed` sub-groups) to actually update individual submodule versions, each Maven submodule +must declare its own `` in its `pom.xml`. Submodules that inherit `` from the parent only bump together +with the parent. + +## 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. @@ -57,4 +106,8 @@ To delegate versioning to the Release Maven Plugin, you can use the `ChangesetsV ``` -Goals should then be invoked as `changesets:prepare release:prepare release:perform`. `changesets:release` should *not* be used. \ No newline at end of file +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. \ No newline at end of file diff --git a/changesets-java/src/main/java/se/fortnox/changesets/BumpPlanner.java b/changesets-java/src/main/java/se/fortnox/changesets/BumpPlanner.java new file mode 100644 index 0000000..d6628f6 --- /dev/null +++ b/changesets-java/src/main/java/se/fortnox/changesets/BumpPlanner.java @@ -0,0 +1,154 @@ +package se.fortnox.changesets; + +import org.slf4j.Logger; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +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.TreeMap; +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. + *

+ * 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 changesets) { + public boolean isVersionChange() { + return !currentVersion.equals(newVersion); + } + } + + public static Map plan( + List changesets, + Map reactor, + ChangesetsConfig config + ) { + Set known = reactor.keySet(); + List known_changesets = filterKnownModules(changesets, known); + + List groups = buildGroups(reactor, config); + + Map result = new LinkedHashMap<>(); + for (Group group : groups) { + List groupChangesets = changesetsForGroup(known_changesets, group); + if (groupChangesets.isEmpty()) { + continue; + } + result.putAll(planGroup(group, groupChangesets, reactor)); + } + return result; + } + + private static List filterKnownModules(List changesets, Set known) { + List 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 ? "" : c.file().getName(), + c.packageName()); + } + } + return kept; + } + + private static List buildGroups(Map reactor, ChangesetsConfig config) { + if (config.versioning() == FIXED) { + return List.of(new Group(GroupKind.FIXED, new LinkedHashSet<>(reactor.keySet()))); + } + + List groups = new ArrayList<>(); + Set assigned = new HashSet<>(); + for (List g : config.fixed()) { + groups.add(new Group(GroupKind.FIXED, new LinkedHashSet<>(g))); + assigned.addAll(g); + } + for (List 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 changesetsForGroup(List changesets, Group group) { + return changesets.stream() + .filter(c -> group.members().contains(c.packageName())) + .toList(); + } + + private static Map planGroup(Group group, List groupChangesets, Map reactor) { + String baseVersion = highestVersion(group.members().stream() + .map(reactor::get) + .filter(java.util.Objects::nonNull) + .toList()); + String newVersion = VersionCalculator.getNewVersion(baseVersion, groupChangesets); + + Set 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 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 result = new LinkedHashMap<>(); + for (String member : bumpMembers) { + String currentVersion = reactor.get(member); + if (currentVersion == null) { + continue; + } + List 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 versions) { + TreeMap sorted = new TreeMap<>(); + for (String v : versions) { + org.semver4j.Semver semver = Optional.ofNullable(org.semver4j.Semver.coerce(v)) + .map(org.semver4j.Semver::withClearedPreReleaseAndBuild) + .orElseThrow(() -> new IllegalArgumentException("Cannot coerce \"%s\" into a semantic version.".formatted(v))); + sorted.put(semver, v); + } + if (sorted.isEmpty()) { + throw new IllegalStateException("Group has no resolvable members in reactor"); + } + return sorted.lastEntry().getValue(); + } + + private enum GroupKind { FIXED, LINKED, INDIVIDUAL } + + private record Group(GroupKind kind, Set members) {} +} diff --git a/changesets-java/src/main/java/se/fortnox/changesets/ChangelogAggregator.java b/changesets-java/src/main/java/se/fortnox/changesets/ChangelogAggregator.java index f7706ea..4f57d2a 100644 --- a/changesets-java/src/main/java/se/fortnox/changesets/ChangelogAggregator.java +++ b/changesets-java/src/main/java/se/fortnox/changesets/ChangelogAggregator.java @@ -78,8 +78,95 @@ public Path mergeChangesetsToChangelog(String packageName, String version) { return changelogFile; } + /** + * Merge a multi-module release into the root CHANGELOG.md: one section per module that + * bumped, ordered by the {@code moduleEntries} iteration order. Consumes (deletes) all + * passed-in changeset files. Modules with empty changeset lists are skipped. + * + * @param moduleEntries Ordered map of artifactId → (newVersion, changesets) for the release + * @return Path to the written CHANGELOG.md, or the changeset dir if nothing was written + */ + public Path mergeReleaseToChangelog(Map moduleEntries) { + Path changesetsDir = this.baseDir.resolve(CHANGESET_DIR); + + List nonEmpty = moduleEntries.values().stream() + .filter(e -> !e.changesets().isEmpty()) + .toList(); + if (nonEmpty.isEmpty()) { + LOG.info("No changesets to write to changelog in {}", this.baseDir); + return changesetsDir; + } + + String changelog = generateMultiModuleChangelog(moduleEntries); + + Path changelogFile; + try { + changelogFile = writeChangelog(changelog); + } catch (ChangelogException exception) { + LOG.error("Failed to update changelog at {}", changesetsDir, exception); + return changesetsDir; + } + + deleteConsumedChangesets(nonEmpty); + return changelogFile; + } + + public record ReleaseEntry(String artifactId, String newVersion, List changesets) {} + + private void deleteConsumedChangesets(List entries) { + entries.stream() + .flatMap(e -> e.changesets().stream()) + .map(Changeset::file) + .filter(java.util.Objects::nonNull) + .distinct() + .forEach(file -> { + try { + Files.deleteIfExists(file.toPath()); + } catch (IOException e) { + LOG.error("Failed to delete {}", file, e); + } + }); + } + + private String generateMultiModuleChangelog(Map moduleEntries) { + String body = moduleEntries.values().stream() + .filter(e -> !e.changesets().isEmpty()) + .map(this::generateModuleSection) + .collect(Collectors.joining("\n\n")); + + String markdown = """ + # Changelog + + %s + """.formatted(body); + + return MarkdownFormatter.format(markdown); + } + + private String generateModuleSection(ReleaseEntry entry) { + String changes = renderChangesByLevel(entry.changesets(), "###"); + return """ + ## %s@%s + + %s""".formatted(entry.artifactId(), entry.newVersion(), changes); + } + private String generateChangelog(String packageName, String version, List changesets) { - String changes = changesets + String changes = renderChangesByLevel(changesets, "###"); + + String markdown = """ + # %s + + ## %s + + %s + """.formatted(packageName, version, changes); + + return MarkdownFormatter.format(markdown); + } + + private String renderChangesByLevel(List changesets, String headingPrefix) { + return changesets .stream() .collect(groupingBy(Changeset::level, mapping(Changeset::message, toList()))) .entrySet() @@ -91,21 +178,11 @@ private String generateChangelog(String packageName, String version, List changes) { diff --git a/changesets-java/src/main/java/se/fortnox/changesets/ChangesetLocator.java b/changesets-java/src/main/java/se/fortnox/changesets/ChangesetLocator.java index 2cde952..27a4342 100644 --- a/changesets-java/src/main/java/se/fortnox/changesets/ChangesetLocator.java +++ b/changesets-java/src/main/java/se/fortnox/changesets/ChangesetLocator.java @@ -21,34 +21,35 @@ public ChangesetLocator(Path baseDir) { } public List getChangesets(String packageName) { - Path changesetsDir = this.baseDir.resolve(CHANGESET_DIR); - File[] array = changesetsDir.toFile().listFiles((dir, name) -> name.endsWith(".md")); - - if (array == null) { - LOG.debug("No changesets found in {}", changesetsDir); - return new ArrayList<>(); - } - - List changesets = Arrays.stream(Objects.requireNonNull(array)) - .sorted() - .toList(); - - List matchingChangesets = changesets.stream() - .flatMap(file -> ChangesetParser.parseFile(file).stream()) + List matchingChangesets = getAllChangesets().stream() .filter(changeset -> { boolean matchesPackage = changeset.packageName().equals(packageName); if (!matchesPackage) { LOG.info("Found {}, but {} did not match requested packagename {}", changeset.file(), changeset.packageName(), packageName); } - return matchesPackage; }) .toList(); if (matchingChangesets.isEmpty()) { - LOG.info("No changesets matching package {} found in {}", packageName, changesetsDir); + LOG.info("No changesets matching package {} found in {}", packageName, this.baseDir.resolve(CHANGESET_DIR)); } return matchingChangesets; } + + public List getAllChangesets() { + Path changesetsDir = this.baseDir.resolve(CHANGESET_DIR); + File[] array = changesetsDir.toFile().listFiles((dir, name) -> name.endsWith(".md")); + + if (array == null) { + LOG.debug("No changesets found in {}", changesetsDir); + return new ArrayList<>(); + } + + return Arrays.stream(Objects.requireNonNull(array)) + .sorted() + .flatMap(file -> ChangesetParser.parseFile(file).stream()) + .toList(); + } } diff --git a/changesets-java/src/main/java/se/fortnox/changesets/ChangesetsConfig.java b/changesets-java/src/main/java/se/fortnox/changesets/ChangesetsConfig.java new file mode 100644 index 0000000..2350a9a --- /dev/null +++ b/changesets-java/src/main/java/se/fortnox/changesets/ChangesetsConfig.java @@ -0,0 +1,103 @@ +package se.fortnox.changesets; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.slf4j.Logger; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import static org.slf4j.LoggerFactory.getLogger; + +public record ChangesetsConfig( + VersioningStrategy versioning, + List> linked, + List> fixed, + ChangelogMode changelog +) { + private static final Logger LOG = getLogger(ChangesetsConfig.class); + public static final String CONFIG_FILE = "config.json"; + + public ChangesetsConfig { + versioning = versioning == null ? VersioningStrategy.FIXED : versioning; + linked = linked == null ? List.of() : List.copyOf(linked); + fixed = fixed == null ? List.of() : List.copyOf(fixed); + changelog = changelog == null ? ChangelogMode.ROOT : changelog; + validateGroupsAreDisjoint(linked, fixed); + } + + public static ChangesetsConfig defaults() { + return new ChangesetsConfig(VersioningStrategy.FIXED, List.of(), List.of(), ChangelogMode.ROOT); + } + + /** + * Read .changeset/config.json from the given changesets directory. + * Returns defaults if the file does not exist or cannot be parsed. + */ + public static ChangesetsConfig load(Path changesetsDir) { + Path configFile = changesetsDir.resolve(CONFIG_FILE); + if (!Files.exists(configFile)) { + return defaults(); + } + try { + String json = Files.readString(configFile); + return new ObjectMapper().readValue(json, ChangesetsConfig.class); + } catch (IOException e) { + LOG.error("Failed to read changesets config at {}, falling back to defaults", configFile, e); + return defaults(); + } + } + + private static void validateGroupsAreDisjoint(List> linked, List> fixed) { + Set seen = new HashSet<>(); + List> allGroups = new ArrayList<>(linked.size() + fixed.size()); + allGroups.addAll(linked); + allGroups.addAll(fixed); + for (List group : allGroups) { + for (String name : group) { + if (!seen.add(name)) { + throw new IllegalArgumentException( + "Module '" + name + "' appears in multiple linked/fixed groups"); + } + } + } + } + + public enum VersioningStrategy { + @JsonProperty("fixed") FIXED, + @JsonProperty("independent") INDEPENDENT; + + @JsonCreator + public static VersioningStrategy fromString(String value) { + if (value == null) { + return FIXED; + } + return switch (value.toLowerCase()) { + case "fixed" -> FIXED; + case "independent" -> INDEPENDENT; + default -> throw new IllegalArgumentException("Unknown versioning strategy: " + value); + }; + } + } + + public enum ChangelogMode { + @JsonProperty("root") ROOT; + + @JsonCreator + public static ChangelogMode fromString(String value) { + if (value == null) { + return ROOT; + } + return switch (value.toLowerCase()) { + case "root" -> ROOT; + default -> throw new IllegalArgumentException("Unknown changelog mode: " + value); + }; + } + } +} diff --git a/changesets-java/src/test/java/se/fortnox/changesets/BumpPlannerTest.java b/changesets-java/src/test/java/se/fortnox/changesets/BumpPlannerTest.java new file mode 100644 index 0000000..82d1434 --- /dev/null +++ b/changesets-java/src/test/java/se/fortnox/changesets/BumpPlannerTest.java @@ -0,0 +1,202 @@ +package se.fortnox.changesets; + +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.io.File; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static se.fortnox.changesets.ChangesetsConfig.ChangelogMode.ROOT; +import static se.fortnox.changesets.ChangesetsConfig.VersioningStrategy.FIXED; +import static se.fortnox.changesets.ChangesetsConfig.VersioningStrategy.INDEPENDENT; + +class BumpPlannerTest { + + private static Changeset changeset(String packageName, Level level) { + return new Changeset(packageName, level, "msg", new File("dummy.md")); + } + + @Nested + class FixedStrategy { + @Test + void allModulesBumpTogetherOnAnyChangeset() { + var reactor = Map.of("root", "1.0.0", "m1", "1.0.0", "m2", "1.0.0"); + var changes = List.of(changeset("m1", Level.MINOR)); + + var result = BumpPlanner.plan(changes, reactor, ChangesetsConfig.defaults()); + + assertThat(result).hasSize(3); + assertThat(result.get("root").newVersion()).isEqualTo("1.1.0"); + assertThat(result.get("m1").newVersion()).isEqualTo("1.1.0"); + assertThat(result.get("m2").newVersion()).isEqualTo("1.1.0"); + } + + @Test + void noChangesetsProducesEmptyResult() { + var reactor = Map.of("root", "1.0.0", "m1", "1.0.0"); + + var result = BumpPlanner.plan(List.of(), reactor, ChangesetsConfig.defaults()); + + assertThat(result).isEmpty(); + } + + @Test + void highestBumpLevelWinsAcrossAllChangesets() { + var reactor = Map.of("root", "1.0.0", "m1", "1.0.0"); + var changes = List.of( + changeset("m1", Level.PATCH), + changeset("root", Level.MAJOR)); + + var result = BumpPlanner.plan(changes, reactor, ChangesetsConfig.defaults()); + + assertThat(result.get("m1").newVersion()).isEqualTo("2.0.0"); + assertThat(result.get("root").newVersion()).isEqualTo("2.0.0"); + } + + @Test + void baseVersionIsMaxOfReactorWhenVersionsDiffer() { + var reactor = Map.of("root", "1.0.0", "m1", "2.0.0", "m2", "1.5.0"); + var changes = List.of(changeset("m1", Level.PATCH)); + + var result = BumpPlanner.plan(changes, reactor, ChangesetsConfig.defaults()); + + assertThat(result.values()).allMatch(b -> b.newVersion().equals("2.0.1")); + } + } + + @Nested + class IndependentStrategy { + @Test + void modulesWithoutGroupsBumpIndependently() { + var reactor = Map.of("m1", "1.0.0", "m2", "2.0.0"); + var changes = List.of( + changeset("m1", Level.MINOR), + changeset("m2", Level.PATCH)); + var config = new ChangesetsConfig(INDEPENDENT, List.of(), List.of(), ROOT); + + var result = BumpPlanner.plan(changes, reactor, config); + + assertThat(result.get("m1").newVersion()).isEqualTo("1.1.0"); + assertThat(result.get("m2").newVersion()).isEqualTo("2.0.1"); + } + + @Test + void modulesWithoutChangesetsAreOmitted() { + var reactor = Map.of("m1", "1.0.0", "m2", "1.0.0"); + var changes = List.of(changeset("m1", Level.PATCH)); + var config = new ChangesetsConfig(INDEPENDENT, List.of(), List.of(), ROOT); + + var result = BumpPlanner.plan(changes, reactor, config); + + assertThat(result).containsOnlyKeys("m1"); + assertThat(result.get("m1").newVersion()).isEqualTo("1.0.1"); + } + } + + @Nested + class LinkedGroups { + @Test + void npmDocsExample_pkgA_patch_pkgB_minor_bothAt1_0_0() { + // From changesets.dev/guide/linked-packages Release 1 + var reactor = Map.of("pkg-a", "1.0.0", "pkg-b", "1.0.0", "pkg-c", "1.0.0"); + var changes = List.of( + changeset("pkg-a", Level.PATCH), + changeset("pkg-b", Level.MINOR), + changeset("pkg-c", Level.MAJOR)); + var config = new ChangesetsConfig( + INDEPENDENT, + List.of(List.of("pkg-a", "pkg-b")), + List.of(), + ROOT); + + var result = BumpPlanner.plan(changes, reactor, config); + + assertThat(result.get("pkg-a").newVersion()).isEqualTo("1.1.0"); + assertThat(result.get("pkg-b").newVersion()).isEqualTo("1.1.0"); + assertThat(result.get("pkg-c").newVersion()).isEqualTo("2.0.0"); + } + + @Test + void onlyActiveMembersBumpInLinkedGroup() { + var reactor = Map.of("pkg-a", "1.0.0", "pkg-b", "1.0.0"); + var changes = List.of(changeset("pkg-a", Level.MINOR)); + var config = new ChangesetsConfig( + INDEPENDENT, + List.of(List.of("pkg-a", "pkg-b")), + List.of(), + ROOT); + + var result = BumpPlanner.plan(changes, reactor, config); + + assertThat(result).containsOnlyKeys("pkg-a"); + assertThat(result.get("pkg-a").newVersion()).isEqualTo("1.1.0"); + } + + @Test + void baseVersionIsMaxAcrossAllLinkedMembersIncludingInactive() { + var reactor = Map.of("pkg-a", "1.0.0", "pkg-b", "2.5.0"); + var changes = List.of(changeset("pkg-a", Level.PATCH)); + var config = new ChangesetsConfig( + INDEPENDENT, + List.of(List.of("pkg-a", "pkg-b")), + List.of(), + ROOT); + + var result = BumpPlanner.plan(changes, reactor, config); + + assertThat(result).containsOnlyKeys("pkg-a"); + assertThat(result.get("pkg-a").newVersion()).isEqualTo("2.5.1"); + } + } + + @Nested + class FixedGroups { + @Test + void allFixedMembersBumpWhenAnyHasChangeset() { + var reactor = Map.of("pkg-a", "1.0.0", "pkg-b", "1.0.0", "pkg-c", "1.0.0"); + var changes = List.of(changeset("pkg-a", Level.MINOR)); + var config = new ChangesetsConfig( + INDEPENDENT, + List.of(), + List.of(List.of("pkg-a", "pkg-b")), + ROOT); + + var result = BumpPlanner.plan(changes, reactor, config); + + assertThat(result.get("pkg-a").newVersion()).isEqualTo("1.1.0"); + assertThat(result.get("pkg-b").newVersion()).isEqualTo("1.1.0"); + assertThat(result).doesNotContainKey("pkg-c"); + } + } + + @Nested + class UnknownModules { + @Test + void changesetForUnknownModuleIsIgnored() { + var reactor = Map.of("m1", "1.0.0"); + var changes = List.of(changeset("unknown", Level.PATCH)); + + var result = BumpPlanner.plan(changes, reactor, ChangesetsConfig.defaults()); + + assertThat(result).isEmpty(); + } + } + + @Nested + class DependencyOnly { + @Test + void dependencyOnlyChangesetEmitsBumpWithUnchangedVersion() { + var reactor = Map.of("m1", "1.0.0"); + var changes = List.of(changeset("m1", Level.DEPENDENCY)); + var config = new ChangesetsConfig(INDEPENDENT, List.of(), List.of(), ROOT); + + var result = BumpPlanner.plan(changes, reactor, config); + + assertThat(result).containsOnlyKeys("m1"); + assertThat(result.get("m1").newVersion()).isEqualTo("1.0.0"); + assertThat(result.get("m1").isVersionChange()).isFalse(); + } + } +} diff --git a/changesets-java/src/test/java/se/fortnox/changesets/ChangelogAggregatorTest.java b/changesets-java/src/test/java/se/fortnox/changesets/ChangelogAggregatorTest.java index 6f6c6d9..c536f96 100644 --- a/changesets-java/src/test/java/se/fortnox/changesets/ChangelogAggregatorTest.java +++ b/changesets-java/src/test/java/se/fortnox/changesets/ChangelogAggregatorTest.java @@ -4,11 +4,15 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; +import java.io.File; import java.io.IOException; import java.nio.file.FileAlreadyExistsException; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardOpenOption; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; import static org.assertj.core.api.Assertions.assertThat; import static se.fortnox.changesets.ChangelogAggregator.CHANGELOG_FILE; @@ -297,7 +301,97 @@ void shouldAggregateDependencyUpdates(@TempDir Path tempDir) throws FileAlreadyE that should be kept as a single item - Some dependency - Third dependency - + + """); + } + + @Test + void mergeReleaseToChangelog_writesMultiModuleBlock(@TempDir Path tempDir) throws Exception { + ChangesetWriter writer = new ChangesetWriter(tempDir); + Path patchFile = writer.writeChangeset("pkg-a", Level.PATCH, "Patched A"); + Path minorFile = writer.writeChangeset("pkg-b", Level.MINOR, "Added B feature"); + + Changeset patchChangeset = new Changeset("pkg-a", Level.PATCH, "Patched A", patchFile.toFile()); + Changeset minorChangeset = new Changeset("pkg-b", Level.MINOR, "Added B feature", minorFile.toFile()); + + Map entries = new LinkedHashMap<>(); + entries.put("pkg-a", new ChangelogAggregator.ReleaseEntry("pkg-a", "1.2.3", List.of(patchChangeset))); + entries.put("pkg-b", new ChangelogAggregator.ReleaseEntry("pkg-b", "2.0.0", List.of(minorChangeset))); + + new ChangelogAggregator(tempDir).mergeReleaseToChangelog(entries); + + assertThat(tempDir.resolve(CHANGELOG_FILE)) + .exists() + .content() + .isEqualTo(""" + # Changelog + + ## pkg-a@1.2.3 + + ### Patch Changes + + - Patched A + + ## pkg-b@2.0.0 + + ### Minor Changes + + - Added B feature + + """); + + assertThat(patchFile).doesNotExist(); + assertThat(minorFile).doesNotExist(); + } + + @Test + void mergeReleaseToChangelog_prependsToExistingChangelog(@TempDir Path tempDir) throws Exception { + Files.writeString(tempDir.resolve(CHANGELOG_FILE), """ + # Changelog + + ## pkg-a@1.0.0 + + ### Patch Changes + + - Old change + """, StandardOpenOption.CREATE_NEW); + + ChangesetWriter writer = new ChangesetWriter(tempDir); + Path file = writer.writeChangeset("pkg-a", Level.MINOR, "Newer change"); + Changeset cs = new Changeset("pkg-a", Level.MINOR, "Newer change", file.toFile()); + + Map entries = new LinkedHashMap<>(); + entries.put("pkg-a", new ChangelogAggregator.ReleaseEntry("pkg-a", "1.1.0", List.of(cs))); + + new ChangelogAggregator(tempDir).mergeReleaseToChangelog(entries); + + assertThat(tempDir.resolve(CHANGELOG_FILE)) + .content() + .isEqualTo(""" + # Changelog + + ## pkg-a@1.1.0 + + ### Minor Changes + + - Newer change + + + ## pkg-a@1.0.0 + + ### Patch Changes + + - Old change """); } + + @Test + void mergeReleaseToChangelog_skipsModulesWithNoChangesets(@TempDir Path tempDir) { + Map entries = new LinkedHashMap<>(); + entries.put("pkg-a", new ChangelogAggregator.ReleaseEntry("pkg-a", "1.0.0", List.of())); + + new ChangelogAggregator(tempDir).mergeReleaseToChangelog(entries); + + assertThat(tempDir.resolve(CHANGELOG_FILE)).doesNotExist(); + } } \ No newline at end of file diff --git a/changesets-java/src/test/java/se/fortnox/changesets/ChangesetsConfigTest.java b/changesets-java/src/test/java/se/fortnox/changesets/ChangesetsConfigTest.java new file mode 100644 index 0000000..7599373 --- /dev/null +++ b/changesets-java/src/test/java/se/fortnox/changesets/ChangesetsConfigTest.java @@ -0,0 +1,134 @@ +package se.fortnox.changesets; + +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static se.fortnox.changesets.ChangesetsConfig.ChangelogMode.ROOT; +import static se.fortnox.changesets.ChangesetsConfig.VersioningStrategy.FIXED; +import static se.fortnox.changesets.ChangesetsConfig.VersioningStrategy.INDEPENDENT; + +class ChangesetsConfigTest { + + @Nested + class Defaults { + @Test + void defaultsToFixedVersioningAndRootChangelog() { + var config = ChangesetsConfig.defaults(); + + assertThat(config.versioning()).isEqualTo(FIXED); + assertThat(config.linked()).isEmpty(); + assertThat(config.fixed()).isEmpty(); + assertThat(config.changelog()).isEqualTo(ROOT); + } + + @Test + void canonicalConstructorFillsNullsWithDefaults() { + var config = new ChangesetsConfig(null, null, null, null); + + assertThat(config.versioning()).isEqualTo(FIXED); + assertThat(config.linked()).isEmpty(); + assertThat(config.fixed()).isEmpty(); + assertThat(config.changelog()).isEqualTo(ROOT); + } + } + + @Nested + class Loading { + @TempDir + Path tempDir; + + @Test + void returnsDefaultsWhenConfigFileMissing() { + var config = ChangesetsConfig.load(tempDir); + + assertThat(config).isEqualTo(ChangesetsConfig.defaults()); + } + + @Test + void parsesFullyPopulatedConfig() throws IOException { + Files.writeString(tempDir.resolve("config.json"), """ + { + "versioning": "independent", + "linked": [["pkg-a", "pkg-b"]], + "fixed": [["pkg-c", "pkg-d"]], + "changelog": "root" + } + """); + + var config = ChangesetsConfig.load(tempDir); + + assertThat(config.versioning()).isEqualTo(INDEPENDENT); + assertThat(config.linked()).containsExactly(List.of("pkg-a", "pkg-b")); + assertThat(config.fixed()).containsExactly(List.of("pkg-c", "pkg-d")); + assertThat(config.changelog()).isEqualTo(ROOT); + } + + @Test + void appliesDefaultsForMissingFields() throws IOException { + Files.writeString(tempDir.resolve("config.json"), """ + { "versioning": "independent" } + """); + + var config = ChangesetsConfig.load(tempDir); + + assertThat(config.versioning()).isEqualTo(INDEPENDENT); + assertThat(config.linked()).isEmpty(); + assertThat(config.fixed()).isEmpty(); + assertThat(config.changelog()).isEqualTo(ROOT); + } + + @Test + void returnsDefaultsOnMalformedJson() throws IOException { + Files.writeString(tempDir.resolve("config.json"), "not json {"); + + var config = ChangesetsConfig.load(tempDir); + + assertThat(config).isEqualTo(ChangesetsConfig.defaults()); + } + } + + @Nested + class Validation { + @Test + void rejectsModuleInTwoLinkedGroups() { + assertThatThrownBy(() -> new ChangesetsConfig( + INDEPENDENT, + List.of(List.of("pkg-a", "pkg-b"), List.of("pkg-a", "pkg-c")), + List.of(), + ROOT)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("pkg-a"); + } + + @Test + void rejectsModuleInBothLinkedAndFixed() { + assertThatThrownBy(() -> new ChangesetsConfig( + INDEPENDENT, + List.of(List.of("pkg-a", "pkg-b")), + List.of(List.of("pkg-a", "pkg-c")), + ROOT)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("pkg-a"); + } + + @Test + void allowsDistinctGroups() { + var config = new ChangesetsConfig( + INDEPENDENT, + List.of(List.of("pkg-a", "pkg-b")), + List.of(List.of("pkg-c", "pkg-d")), + ROOT); + + assertThat(config.linked()).hasSize(1); + assertThat(config.fixed()).hasSize(1); + } + } +} diff --git a/changesets-maven-plugin/src/it/fixed-groups/.changeset/bump-a.md b/changesets-maven-plugin/src/it/fixed-groups/.changeset/bump-a.md new file mode 100644 index 0000000..5320fb4 --- /dev/null +++ b/changesets-maven-plugin/src/it/fixed-groups/.changeset/bump-a.md @@ -0,0 +1,5 @@ +--- +"module-a": minor +--- + +A change in module-a diff --git a/changesets-maven-plugin/src/it/fixed-groups/.changeset/config.json b/changesets-maven-plugin/src/it/fixed-groups/.changeset/config.json new file mode 100644 index 0000000..983bae7 --- /dev/null +++ b/changesets-maven-plugin/src/it/fixed-groups/.changeset/config.json @@ -0,0 +1,4 @@ +{ + "versioning": "independent", + "fixed": [["module-a", "module-b"]] +} diff --git a/changesets-maven-plugin/src/it/fixed-groups/EXPECTED_CHANGELOG.md b/changesets-maven-plugin/src/it/fixed-groups/EXPECTED_CHANGELOG.md new file mode 100644 index 0000000..0029e36 --- /dev/null +++ b/changesets-maven-plugin/src/it/fixed-groups/EXPECTED_CHANGELOG.md @@ -0,0 +1,8 @@ +# Changelog + +## module-a@1.3.0 + +### Minor Changes + +- A change in module-a + diff --git a/changesets-maven-plugin/src/it/prepare-no-version/invoker.properties b/changesets-maven-plugin/src/it/fixed-groups/invoker.properties similarity index 78% rename from changesets-maven-plugin/src/it/prepare-no-version/invoker.properties rename to changesets-maven-plugin/src/it/fixed-groups/invoker.properties index 2171af2..dce7e2c 100644 --- a/changesets-maven-plugin/src/it/prepare-no-version/invoker.properties +++ b/changesets-maven-plugin/src/it/fixed-groups/invoker.properties @@ -1 +1 @@ -invoker.goals=${project.groupId}:${project.artifactId}:${project.version}:prepare \ No newline at end of file +invoker.goals=${project.groupId}:${project.artifactId}:${project.version}:prepare diff --git a/changesets-maven-plugin/src/it/fixed-groups/module-a/pom.xml b/changesets-maven-plugin/src/it/fixed-groups/module-a/pom.xml new file mode 100644 index 0000000..ada20fd --- /dev/null +++ b/changesets-maven-plugin/src/it/fixed-groups/module-a/pom.xml @@ -0,0 +1,12 @@ + + + 4.0.0 + module-a + 1.0.0 + + se.fortnox.maven.it + fixed-root + 1.0.0 + + diff --git a/changesets-maven-plugin/src/it/fixed-groups/module-b/pom.xml b/changesets-maven-plugin/src/it/fixed-groups/module-b/pom.xml new file mode 100644 index 0000000..7b38e5b --- /dev/null +++ b/changesets-maven-plugin/src/it/fixed-groups/module-b/pom.xml @@ -0,0 +1,12 @@ + + + 4.0.0 + module-b + 1.2.0 + + se.fortnox.maven.it + fixed-root + 1.0.0 + + diff --git a/changesets-maven-plugin/src/it/fixed-groups/module-c/pom.xml b/changesets-maven-plugin/src/it/fixed-groups/module-c/pom.xml new file mode 100644 index 0000000..d31a2d8 --- /dev/null +++ b/changesets-maven-plugin/src/it/fixed-groups/module-c/pom.xml @@ -0,0 +1,12 @@ + + + 4.0.0 + module-c + 5.0.0 + + se.fortnox.maven.it + fixed-root + 1.0.0 + + diff --git a/changesets-maven-plugin/src/it/fixed-groups/pom.xml b/changesets-maven-plugin/src/it/fixed-groups/pom.xml new file mode 100644 index 0000000..28566c7 --- /dev/null +++ b/changesets-maven-plugin/src/it/fixed-groups/pom.xml @@ -0,0 +1,21 @@ + + + 4.0.0 + + se.fortnox.maven.it + fixed-root + 1.0.0 + pom + + IT: fixed sub-group inside independent mode. One changeset bumps all members of the group. + + + UTF-8 + + + module-a + module-b + module-c + + diff --git a/changesets-maven-plugin/src/it/fixed-groups/verify.groovy b/changesets-maven-plugin/src/it/fixed-groups/verify.groovy new file mode 100644 index 0000000..18213d8 --- /dev/null +++ b/changesets-maven-plugin/src/it/fixed-groups/verify.groovy @@ -0,0 +1,26 @@ +import groovy.xml.XmlSlurper + +import static org.assertj.core.api.Assertions.assertThat + +// Fixed group [module-a, module-b]: both bump (even though only module-a had a changeset). +// Base = max(1.0.0, 1.2.0) = 1.2.0; minor bump → 1.3.0 for both. +// module-c is not in the group and has no changeset → no bump. +def versions = new File(basedir, '.changeset/VERSIONS').text +assertThat(versions).contains("module-a=1.3.0") +assertThat(versions).contains("module-b=1.3.0") +assertThat(versions).doesNotContain("module-c=") +assertThat(versions).doesNotContain("fixed-root=") + +def moduleA = new XmlSlurper().parse(new File(basedir, 'module-a/pom.xml')) +assertThat(moduleA.version).isEqualTo('1.3.1-SNAPSHOT') + +def moduleB = new XmlSlurper().parse(new File(basedir, 'module-b/pom.xml')) +assertThat(moduleB.version).isEqualTo('1.3.1-SNAPSHOT') + +def moduleC = new XmlSlurper().parse(new File(basedir, 'module-c/pom.xml')) +assertThat(moduleC.version).isEqualTo('5.0.0') + +assertThat(new File(basedir, 'CHANGELOG.md')) + .hasSameTextualContentAs(new File(basedir, 'EXPECTED_CHANGELOG.md')) + +true diff --git a/changesets-maven-plugin/src/it/independent-versioning/.changeset/bump-a.md b/changesets-maven-plugin/src/it/independent-versioning/.changeset/bump-a.md new file mode 100644 index 0000000..4ae38ea --- /dev/null +++ b/changesets-maven-plugin/src/it/independent-versioning/.changeset/bump-a.md @@ -0,0 +1,5 @@ +--- +"module-a": minor +--- + +Added module-a feature diff --git a/changesets-maven-plugin/src/it/independent-versioning/.changeset/bump-b.md b/changesets-maven-plugin/src/it/independent-versioning/.changeset/bump-b.md new file mode 100644 index 0000000..1881197 --- /dev/null +++ b/changesets-maven-plugin/src/it/independent-versioning/.changeset/bump-b.md @@ -0,0 +1,5 @@ +--- +"module-b": patch +--- + +Tiny module-b fix diff --git a/changesets-maven-plugin/src/it/independent-versioning/.changeset/config.json b/changesets-maven-plugin/src/it/independent-versioning/.changeset/config.json new file mode 100644 index 0000000..d19875b --- /dev/null +++ b/changesets-maven-plugin/src/it/independent-versioning/.changeset/config.json @@ -0,0 +1,3 @@ +{ + "versioning": "independent" +} diff --git a/changesets-maven-plugin/src/it/independent-versioning/EXPECTED_CHANGELOG.md b/changesets-maven-plugin/src/it/independent-versioning/EXPECTED_CHANGELOG.md new file mode 100644 index 0000000..e598742 --- /dev/null +++ b/changesets-maven-plugin/src/it/independent-versioning/EXPECTED_CHANGELOG.md @@ -0,0 +1,14 @@ +# Changelog + +## module-a@2.1.0 + +### Minor Changes + +- Added module-a feature + +## module-b@3.0.1 + +### Patch Changes + +- Tiny module-b fix + diff --git a/changesets-maven-plugin/src/it/independent-versioning/invoker.properties b/changesets-maven-plugin/src/it/independent-versioning/invoker.properties new file mode 100644 index 0000000..dce7e2c --- /dev/null +++ b/changesets-maven-plugin/src/it/independent-versioning/invoker.properties @@ -0,0 +1 @@ +invoker.goals=${project.groupId}:${project.artifactId}:${project.version}:prepare diff --git a/changesets-maven-plugin/src/it/independent-versioning/module-a/pom.xml b/changesets-maven-plugin/src/it/independent-versioning/module-a/pom.xml new file mode 100644 index 0000000..8a6e5e8 --- /dev/null +++ b/changesets-maven-plugin/src/it/independent-versioning/module-a/pom.xml @@ -0,0 +1,12 @@ + + + 4.0.0 + module-a + 2.0.0 + + se.fortnox.maven.it + independent-root + 1.0.0 + + diff --git a/changesets-maven-plugin/src/it/independent-versioning/module-b/pom.xml b/changesets-maven-plugin/src/it/independent-versioning/module-b/pom.xml new file mode 100644 index 0000000..065e048 --- /dev/null +++ b/changesets-maven-plugin/src/it/independent-versioning/module-b/pom.xml @@ -0,0 +1,12 @@ + + + 4.0.0 + module-b + 3.0.0 + + se.fortnox.maven.it + independent-root + 1.0.0 + + diff --git a/changesets-maven-plugin/src/it/independent-versioning/pom.xml b/changesets-maven-plugin/src/it/independent-versioning/pom.xml new file mode 100644 index 0000000..a6b0ca8 --- /dev/null +++ b/changesets-maven-plugin/src/it/independent-versioning/pom.xml @@ -0,0 +1,20 @@ + + + 4.0.0 + + se.fortnox.maven.it + independent-root + 1.0.0 + pom + + IT: independent versioning, each submodule bumped from its own current version. + + + UTF-8 + + + module-a + module-b + + diff --git a/changesets-maven-plugin/src/it/independent-versioning/verify.groovy b/changesets-maven-plugin/src/it/independent-versioning/verify.groovy new file mode 100644 index 0000000..9b396fa --- /dev/null +++ b/changesets-maven-plugin/src/it/independent-versioning/verify.groovy @@ -0,0 +1,25 @@ +import groovy.xml.XmlSlurper + +import static org.assertj.core.api.Assertions.assertThat + +def versions = new File(basedir, '.changeset/VERSIONS').text +assertThat(versions).contains("module-a=2.1.0") +assertThat(versions).contains("module-b=3.0.1") +// Root should NOT bump (no changeset targets it) +assertThat(versions).doesNotContain("independent-root=") + +// Root version unchanged +def rootProject = new XmlSlurper().parse(new File(basedir, 'pom.xml')) +assertThat(rootProject.version).isEqualTo('1.0.0') + +// Submodules each bumped to their own next-dev SNAPSHOT +def moduleA = new XmlSlurper().parse(new File(basedir, 'module-a/pom.xml')) +assertThat(moduleA.version).isEqualTo('2.1.1-SNAPSHOT') + +def moduleB = new XmlSlurper().parse(new File(basedir, 'module-b/pom.xml')) +assertThat(moduleB.version).isEqualTo('3.0.2-SNAPSHOT') + +assertThat(new File(basedir, 'CHANGELOG.md')) + .hasSameTextualContentAs(new File(basedir, 'EXPECTED_CHANGELOG.md')) + +true diff --git a/changesets-maven-plugin/src/it/linked-groups-multi/.changeset/config.json b/changesets-maven-plugin/src/it/linked-groups-multi/.changeset/config.json new file mode 100644 index 0000000..4038b1f --- /dev/null +++ b/changesets-maven-plugin/src/it/linked-groups-multi/.changeset/config.json @@ -0,0 +1,4 @@ +{ + "versioning": "independent", + "linked": [["module-a", "module-b"]] +} diff --git a/changesets-maven-plugin/src/it/linked-groups-multi/.changeset/minor-b.md b/changesets-maven-plugin/src/it/linked-groups-multi/.changeset/minor-b.md new file mode 100644 index 0000000..ab89f8a --- /dev/null +++ b/changesets-maven-plugin/src/it/linked-groups-multi/.changeset/minor-b.md @@ -0,0 +1,5 @@ +--- +"module-b": minor +--- + +Minor change in B diff --git a/changesets-maven-plugin/src/it/linked-groups-multi/.changeset/patch-a.md b/changesets-maven-plugin/src/it/linked-groups-multi/.changeset/patch-a.md new file mode 100644 index 0000000..8709be3 --- /dev/null +++ b/changesets-maven-plugin/src/it/linked-groups-multi/.changeset/patch-a.md @@ -0,0 +1,5 @@ +--- +"module-a": patch +--- + +Patch in A diff --git a/changesets-maven-plugin/src/it/linked-groups-multi/EXPECTED_CHANGELOG.md b/changesets-maven-plugin/src/it/linked-groups-multi/EXPECTED_CHANGELOG.md new file mode 100644 index 0000000..5831015 --- /dev/null +++ b/changesets-maven-plugin/src/it/linked-groups-multi/EXPECTED_CHANGELOG.md @@ -0,0 +1,14 @@ +# Changelog + +## module-a@1.1.0 + +### Patch Changes + +- Patch in A + +## module-b@1.1.0 + +### Minor Changes + +- Minor change in B + diff --git a/changesets-maven-plugin/src/it/linked-groups-multi/invoker.properties b/changesets-maven-plugin/src/it/linked-groups-multi/invoker.properties new file mode 100644 index 0000000..dce7e2c --- /dev/null +++ b/changesets-maven-plugin/src/it/linked-groups-multi/invoker.properties @@ -0,0 +1 @@ +invoker.goals=${project.groupId}:${project.artifactId}:${project.version}:prepare diff --git a/changesets-maven-plugin/src/it/linked-groups-multi/module-a/pom.xml b/changesets-maven-plugin/src/it/linked-groups-multi/module-a/pom.xml new file mode 100644 index 0000000..32005fa --- /dev/null +++ b/changesets-maven-plugin/src/it/linked-groups-multi/module-a/pom.xml @@ -0,0 +1,12 @@ + + + 4.0.0 + module-a + 1.0.0 + + se.fortnox.maven.it + linked-multi-root + 1.0.0 + + diff --git a/changesets-maven-plugin/src/it/linked-groups-multi/module-b/pom.xml b/changesets-maven-plugin/src/it/linked-groups-multi/module-b/pom.xml new file mode 100644 index 0000000..44313f8 --- /dev/null +++ b/changesets-maven-plugin/src/it/linked-groups-multi/module-b/pom.xml @@ -0,0 +1,12 @@ + + + 4.0.0 + module-b + 1.0.0 + + se.fortnox.maven.it + linked-multi-root + 1.0.0 + + diff --git a/changesets-maven-plugin/src/it/linked-groups-multi/pom.xml b/changesets-maven-plugin/src/it/linked-groups-multi/pom.xml new file mode 100644 index 0000000..5d3a4ca --- /dev/null +++ b/changesets-maven-plugin/src/it/linked-groups-multi/pom.xml @@ -0,0 +1,20 @@ + + + 4.0.0 + + se.fortnox.maven.it + linked-multi-root + 1.0.0 + pom + + IT: linked group, both members have changesets — bump together to highest level. + + + UTF-8 + + + module-a + module-b + + diff --git a/changesets-maven-plugin/src/it/linked-groups-multi/verify.groovy b/changesets-maven-plugin/src/it/linked-groups-multi/verify.groovy new file mode 100644 index 0000000..13d40d2 --- /dev/null +++ b/changesets-maven-plugin/src/it/linked-groups-multi/verify.groovy @@ -0,0 +1,20 @@ +import groovy.xml.XmlSlurper + +import static org.assertj.core.api.Assertions.assertThat + +// Both members have changesets at different levels; both bump to the highest-level result +// of the highest current version in the linked set. From [1.0.0, 1.0.0] + minor = 1.1.0. +def versions = new File(basedir, '.changeset/VERSIONS').text +assertThat(versions).contains("module-a=1.1.0") +assertThat(versions).contains("module-b=1.1.0") + +def moduleA = new XmlSlurper().parse(new File(basedir, 'module-a/pom.xml')) +assertThat(moduleA.version).isEqualTo('1.1.1-SNAPSHOT') + +def moduleB = new XmlSlurper().parse(new File(basedir, 'module-b/pom.xml')) +assertThat(moduleB.version).isEqualTo('1.1.1-SNAPSHOT') + +assertThat(new File(basedir, 'CHANGELOG.md')) + .hasSameTextualContentAs(new File(basedir, 'EXPECTED_CHANGELOG.md')) + +true diff --git a/changesets-maven-plugin/src/it/linked-groups/.changeset/bump-a.md b/changesets-maven-plugin/src/it/linked-groups/.changeset/bump-a.md new file mode 100644 index 0000000..fc5b956 --- /dev/null +++ b/changesets-maven-plugin/src/it/linked-groups/.changeset/bump-a.md @@ -0,0 +1,5 @@ +--- +"module-a": minor +--- + +Added feature in A diff --git a/changesets-maven-plugin/src/it/linked-groups/.changeset/config.json b/changesets-maven-plugin/src/it/linked-groups/.changeset/config.json new file mode 100644 index 0000000..4038b1f --- /dev/null +++ b/changesets-maven-plugin/src/it/linked-groups/.changeset/config.json @@ -0,0 +1,4 @@ +{ + "versioning": "independent", + "linked": [["module-a", "module-b"]] +} diff --git a/changesets-maven-plugin/src/it/linked-groups/EXPECTED_CHANGELOG.md b/changesets-maven-plugin/src/it/linked-groups/EXPECTED_CHANGELOG.md new file mode 100644 index 0000000..9666cbf --- /dev/null +++ b/changesets-maven-plugin/src/it/linked-groups/EXPECTED_CHANGELOG.md @@ -0,0 +1,8 @@ +# Changelog + +## module-a@1.1.0 + +### Minor Changes + +- Added feature in A + diff --git a/changesets-maven-plugin/src/it/linked-groups/invoker.properties b/changesets-maven-plugin/src/it/linked-groups/invoker.properties new file mode 100644 index 0000000..dce7e2c --- /dev/null +++ b/changesets-maven-plugin/src/it/linked-groups/invoker.properties @@ -0,0 +1 @@ +invoker.goals=${project.groupId}:${project.artifactId}:${project.version}:prepare diff --git a/changesets-maven-plugin/src/it/linked-groups/module-a/pom.xml b/changesets-maven-plugin/src/it/linked-groups/module-a/pom.xml new file mode 100644 index 0000000..636df32 --- /dev/null +++ b/changesets-maven-plugin/src/it/linked-groups/module-a/pom.xml @@ -0,0 +1,12 @@ + + + 4.0.0 + module-a + 1.0.0 + + se.fortnox.maven.it + linked-root + 1.0.0 + + diff --git a/changesets-maven-plugin/src/it/linked-groups/module-b/pom.xml b/changesets-maven-plugin/src/it/linked-groups/module-b/pom.xml new file mode 100644 index 0000000..d0129cb --- /dev/null +++ b/changesets-maven-plugin/src/it/linked-groups/module-b/pom.xml @@ -0,0 +1,12 @@ + + + 4.0.0 + module-b + 1.0.0 + + se.fortnox.maven.it + linked-root + 1.0.0 + + diff --git a/changesets-maven-plugin/src/it/prepare-no-version/pom.xml b/changesets-maven-plugin/src/it/linked-groups/pom.xml similarity index 63% rename from changesets-maven-plugin/src/it/prepare-no-version/pom.xml rename to changesets-maven-plugin/src/it/linked-groups/pom.xml index 16bcee9..6efe5dc 100644 --- a/changesets-maven-plugin/src/it/prepare-no-version/pom.xml +++ b/changesets-maven-plugin/src/it/linked-groups/pom.xml @@ -4,13 +4,17 @@ 4.0.0 se.fortnox.maven.it - my-package - 1.0.1 + linked-root + 1.0.0 + pom - A simple IT verifying the basic use case. + IT: linked group where only one member has a changeset. UTF-8 - + + module-a + module-b + diff --git a/changesets-maven-plugin/src/it/linked-groups/verify.groovy b/changesets-maven-plugin/src/it/linked-groups/verify.groovy new file mode 100644 index 0000000..7b09920 --- /dev/null +++ b/changesets-maven-plugin/src/it/linked-groups/verify.groovy @@ -0,0 +1,19 @@ +import groovy.xml.XmlSlurper + +import static org.assertj.core.api.Assertions.assertThat + +def versions = new File(basedir, '.changeset/VERSIONS').text +assertThat(versions).contains("module-a=1.1.0") +// Linked: module-b has no changeset, so it MUST NOT bump +assertThat(versions).doesNotContain("module-b=") + +def moduleA = new XmlSlurper().parse(new File(basedir, 'module-a/pom.xml')) +assertThat(moduleA.version).isEqualTo('1.1.1-SNAPSHOT') + +def moduleB = new XmlSlurper().parse(new File(basedir, 'module-b/pom.xml')) +assertThat(moduleB.version).isEqualTo('1.0.0') + +assertThat(new File(basedir, 'CHANGELOG.md')) + .hasSameTextualContentAs(new File(basedir, 'EXPECTED_CHANGELOG.md')) + +true diff --git a/changesets-maven-plugin/src/it/multi-module/.changeset/VERSION b/changesets-maven-plugin/src/it/multi-module/.changeset/VERSION deleted file mode 100644 index afaf360..0000000 --- a/changesets-maven-plugin/src/it/multi-module/.changeset/VERSION +++ /dev/null @@ -1 +0,0 @@ -1.0.0 \ No newline at end of file diff --git a/changesets-maven-plugin/src/it/multi-module/EXPECTED_CHANGELOG.md b/changesets-maven-plugin/src/it/multi-module/EXPECTED_CHANGELOG.md index a904a61..ea44440 100644 --- a/changesets-maven-plugin/src/it/multi-module/EXPECTED_CHANGELOG.md +++ b/changesets-maven-plugin/src/it/multi-module/EXPECTED_CHANGELOG.md @@ -1,6 +1,6 @@ -# multi-module +# Changelog -## 2.0.0 +## multi-module@2.0.0 ### Major Changes diff --git a/changesets-maven-plugin/src/it/multi-module/verify.groovy b/changesets-maven-plugin/src/it/multi-module/verify.groovy index 1d05d17..c73bd77 100644 --- a/changesets-maven-plugin/src/it/multi-module/verify.groovy +++ b/changesets-maven-plugin/src/it/multi-module/verify.groovy @@ -2,28 +2,27 @@ import groovy.xml.XmlSlurper import static org.assertj.core.api.Assertions.assertThat -String expectedVersion = '2.0.0'; -String expectedSnapshot = '2.0.1-SNAPSHOT'; +String expectedVersion = '2.0.0' +String expectedSnapshot = '2.0.1-SNAPSHOT' -// The VERSION file should contain the correct version number -assertThat(new File(basedir, '.changeset/VERSION')) - .content() - .isEqualTo(expectedVersion) +// Default versioning (fixed): all reactor modules appear in VERSIONS at the same version +def versions = new File(basedir, '.changeset/VERSIONS').text +assertThat(versions).contains("multi-module=${expectedVersion}") +assertThat(versions).contains("module1=${expectedVersion}") +assertThat(versions).contains("module2=${expectedVersion}") -// The root pom version should be increased by one patch and be a snapshot +// Root pom version is bumped to next-dev SNAPSHOT def project = new XmlSlurper().parse(new File(basedir, 'pom.xml')) assertThat(project.version).isEqualTo(expectedSnapshot) -// Check that the parent reference is updated to the new version +// Submodule parent refs are synced to the root SNAPSHOT def submodule1 = new XmlSlurper().parse(new File(basedir, 'module1/pom.xml')) assertThat(submodule1.parent.version).isEqualTo(project.version) -// Check that the parent reference is updated to the new version def submodule2 = new XmlSlurper().parse(new File(basedir, 'module2/pom.xml')) assertThat(submodule2.parent.version).isEqualTo(project.version) -// Verify that the CHANGELOG.md has been created correctly assertThat(new File(basedir, 'CHANGELOG.md')) .hasSameTextualContentAs(new File(basedir, 'EXPECTED_CHANGELOG.md')) -true \ No newline at end of file +true diff --git a/changesets-maven-plugin/src/it/prepare-no-version/.changeset/nine-owls-knock.md b/changesets-maven-plugin/src/it/prepare-no-version/.changeset/nine-owls-knock.md deleted file mode 100644 index d7e69f0..0000000 --- a/changesets-maven-plugin/src/it/prepare-no-version/.changeset/nine-owls-knock.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"my-package": patch ---- - -A tiny change diff --git a/changesets-maven-plugin/src/it/prepare-no-version/.changeset/seven-owls-suspect.md b/changesets-maven-plugin/src/it/prepare-no-version/.changeset/seven-owls-suspect.md deleted file mode 100644 index e37c41c..0000000 --- a/changesets-maven-plugin/src/it/prepare-no-version/.changeset/seven-owls-suspect.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"my-package": minor ---- - -A medium change diff --git a/changesets-maven-plugin/src/it/prepare-no-version/EXPECTED_CHANGELOG.md b/changesets-maven-plugin/src/it/prepare-no-version/EXPECTED_CHANGELOG.md deleted file mode 100644 index 8c00114..0000000 --- a/changesets-maven-plugin/src/it/prepare-no-version/EXPECTED_CHANGELOG.md +++ /dev/null @@ -1,12 +0,0 @@ -# my-package - -## 0.1.0 - -### Minor Changes - -- A medium change - -### Patch Changes - -- A tiny change - diff --git a/changesets-maven-plugin/src/it/prepare-no-version/verify.groovy b/changesets-maven-plugin/src/it/prepare-no-version/verify.groovy deleted file mode 100644 index 26e5ac4..0000000 --- a/changesets-maven-plugin/src/it/prepare-no-version/verify.groovy +++ /dev/null @@ -1,22 +0,0 @@ -import groovy.xml.XmlSlurper - -import static org.assertj.core.api.Assertions.assertThat - -// Having no VERSIONS file will be treated as version 0.0.0, then incremented one minor by the changesets -String expectedVersion = '0.1.0'; -String expectedSnapshot = '0.1.1-SNAPSHOT'; - -// The VERSION file should contain the correct version number -assertThat(new File(basedir, '.changeset/VERSION')) - .content() - .isEqualTo(expectedVersion) - -// The root pom version should be increased by one patch and be a snapshot -def project = new XmlSlurper().parse(new File(basedir, 'pom.xml')) -assertThat(project.version).isEqualTo(expectedSnapshot) - -// Verify that the CHANGELOG.md has been created correctly -assertThat(new File(basedir, 'CHANGELOG.md')) - .hasSameTextualContentAs(new File(basedir, 'EXPECTED_CHANGELOG.md')) - -true \ No newline at end of file diff --git a/changesets-maven-plugin/src/it/prepare-release-plugin-integration/.changeset/VERSION b/changesets-maven-plugin/src/it/prepare-release-plugin-integration/.changeset/VERSION deleted file mode 100644 index 26f8b8b..0000000 --- a/changesets-maven-plugin/src/it/prepare-release-plugin-integration/.changeset/VERSION +++ /dev/null @@ -1 +0,0 @@ -2.4.5 \ No newline at end of file diff --git a/changesets-maven-plugin/src/it/prepare-release-plugin-integration/EXPECTED_CHANGELOG.md b/changesets-maven-plugin/src/it/prepare-release-plugin-integration/EXPECTED_CHANGELOG.md index 4104f4f..3f636da 100644 --- a/changesets-maven-plugin/src/it/prepare-release-plugin-integration/EXPECTED_CHANGELOG.md +++ b/changesets-maven-plugin/src/it/prepare-release-plugin-integration/EXPECTED_CHANGELOG.md @@ -1,6 +1,6 @@ -# my-package +# Changelog -## 2.5.0 +## my-package@2.5.0 ### Minor Changes diff --git a/changesets-maven-plugin/src/it/prepare-release-plugin-integration/verify.groovy b/changesets-maven-plugin/src/it/prepare-release-plugin-integration/verify.groovy index f4502bb..ecfca9f 100644 --- a/changesets-maven-plugin/src/it/prepare-release-plugin-integration/verify.groovy +++ b/changesets-maven-plugin/src/it/prepare-release-plugin-integration/verify.groovy @@ -2,22 +2,20 @@ import groovy.xml.XmlSlurper import static org.assertj.core.api.Assertions.assertThat -String expectedVersion = '2.5.0'; -String expectedSnapshot = '2.5.1-SNAPSHOT'; +String expectedVersion = '2.5.0' +String expectedSnapshot = '2.5.1-SNAPSHOT' -// The VERSION file should contain the correct version number -assertThat(new File(basedir, '.changeset/VERSION')) +assertThat(new File(basedir, '.changeset/VERSIONS')) .content() - .isEqualTo(expectedVersion) + .isEqualToIgnoringNewLines("my-package=${expectedVersion}") -// The root pom version should be increased by one patch and be a snapshot +// release:update-versions sets the pom to the next development version derived from VERSIONS def project = new XmlSlurper().parse(new File(basedir, 'pom.xml')) assertThat(project.version).isEqualTo(expectedSnapshot) -// Verify that the CHANGELOG.md has been created correctly assertThat(new File(basedir, 'CHANGELOG.md')) .hasSameTextualContentAs(new File(basedir, 'EXPECTED_CHANGELOG.md')) -def buildLog = new File( basedir, "build.log").text +def buildLog = new File(basedir, "build.log").text assert buildLog =~ /Changesets processed, but not updating POMs due to useReleasePluginIntegration being set to true/ -true \ No newline at end of file +true diff --git a/changesets-maven-plugin/src/it/prepare/.changeset/VERSION b/changesets-maven-plugin/src/it/prepare/.changeset/VERSION deleted file mode 100644 index 26f8b8b..0000000 --- a/changesets-maven-plugin/src/it/prepare/.changeset/VERSION +++ /dev/null @@ -1 +0,0 @@ -2.4.5 \ No newline at end of file diff --git a/changesets-maven-plugin/src/it/prepare/EXPECTED_CHANGELOG.md b/changesets-maven-plugin/src/it/prepare/EXPECTED_CHANGELOG.md index 4104f4f..3f636da 100644 --- a/changesets-maven-plugin/src/it/prepare/EXPECTED_CHANGELOG.md +++ b/changesets-maven-plugin/src/it/prepare/EXPECTED_CHANGELOG.md @@ -1,6 +1,6 @@ -# my-package +# Changelog -## 2.5.0 +## my-package@2.5.0 ### Minor Changes diff --git a/changesets-maven-plugin/src/it/prepare/pom.xml b/changesets-maven-plugin/src/it/prepare/pom.xml index 16bcee9..2e65780 100644 --- a/changesets-maven-plugin/src/it/prepare/pom.xml +++ b/changesets-maven-plugin/src/it/prepare/pom.xml @@ -5,7 +5,7 @@ se.fortnox.maven.it my-package - 1.0.1 + 2.4.5 A simple IT verifying the basic use case. diff --git a/changesets-maven-plugin/src/it/prepare/verify.groovy b/changesets-maven-plugin/src/it/prepare/verify.groovy index eaa9b7f..93b9df2 100644 --- a/changesets-maven-plugin/src/it/prepare/verify.groovy +++ b/changesets-maven-plugin/src/it/prepare/verify.groovy @@ -2,20 +2,17 @@ import groovy.xml.XmlSlurper import static org.assertj.core.api.Assertions.assertThat -String expectedVersion = '2.5.0'; -String expectedSnapshot = '2.5.1-SNAPSHOT'; +String expectedVersion = '2.5.0' +String expectedSnapshot = '2.5.1-SNAPSHOT' -// The VERSION file should contain the correct version number -assertThat(new File(basedir, '.changeset/VERSION')) +assertThat(new File(basedir, '.changeset/VERSIONS')) .content() - .isEqualTo(expectedVersion) + .isEqualToIgnoringNewLines("my-package=${expectedVersion}") -// The root pom version should be increased by one patch and be a snapshot def project = new XmlSlurper().parse(new File(basedir, 'pom.xml')) assertThat(project.version).isEqualTo(expectedSnapshot) -// Verify that the CHANGELOG.md has been created correctly assertThat(new File(basedir, 'CHANGELOG.md')) .hasSameTextualContentAs(new File(basedir, 'EXPECTED_CHANGELOG.md')) -true \ No newline at end of file +true diff --git a/changesets-maven-plugin/src/it/release-multimodule/.changeset/VERSION b/changesets-maven-plugin/src/it/release-multimodule/.changeset/VERSION deleted file mode 100644 index fad066f..0000000 --- a/changesets-maven-plugin/src/it/release-multimodule/.changeset/VERSION +++ /dev/null @@ -1 +0,0 @@ -2.5.0 \ No newline at end of file diff --git a/changesets-maven-plugin/src/it/release-multimodule/.changeset/VERSIONS b/changesets-maven-plugin/src/it/release-multimodule/.changeset/VERSIONS new file mode 100644 index 0000000..eda2756 --- /dev/null +++ b/changesets-maven-plugin/src/it/release-multimodule/.changeset/VERSIONS @@ -0,0 +1,3 @@ +multi-module=2.5.0 +module1=2.5.0 +module2=2.5.0 diff --git a/changesets-maven-plugin/src/it/release-multimodule/verify.groovy b/changesets-maven-plugin/src/it/release-multimodule/verify.groovy index 2160626..10937b0 100644 --- a/changesets-maven-plugin/src/it/release-multimodule/verify.groovy +++ b/changesets-maven-plugin/src/it/release-multimodule/verify.groovy @@ -2,24 +2,16 @@ import groovy.xml.XmlSlurper import static org.assertj.core.api.Assertions.assertThat -String expectedVersion = '2.5.0'; +String expectedVersion = '2.5.0' -// The VERSION file should contain the correct version number -assertThat(new File(basedir, '.changeset/VERSION')) - .content() - .isEqualTo(expectedVersion) - -// The root pom version should have the same value as the VERSION file def project = new XmlSlurper().parse(new File(basedir, 'pom.xml')) assertThat(project.version).isEqualTo(expectedVersion) -// Check that the parent reference is updated to the new version +// Submodules inherit version via ; check the parent ref was synced def submodule1 = new XmlSlurper().parse(new File(basedir, 'module1/pom.xml')) -assertThat(submodule1.parent.version).isEqualTo(project.version) - +assertThat(submodule1.parent.version).isEqualTo(expectedVersion) -// Check that the parent reference is updated to the new version def submodule2 = new XmlSlurper().parse(new File(basedir, 'module2/pom.xml')) -assertThat(submodule2.parent.version).isEqualTo(project.version) +assertThat(submodule2.parent.version).isEqualTo(expectedVersion) -true \ No newline at end of file +true diff --git a/changesets-maven-plugin/src/it/release/.changeset/VERSION b/changesets-maven-plugin/src/it/release/.changeset/VERSION deleted file mode 100644 index fad066f..0000000 --- a/changesets-maven-plugin/src/it/release/.changeset/VERSION +++ /dev/null @@ -1 +0,0 @@ -2.5.0 \ No newline at end of file diff --git a/changesets-maven-plugin/src/it/release/.changeset/VERSIONS b/changesets-maven-plugin/src/it/release/.changeset/VERSIONS new file mode 100644 index 0000000..a5f9aae --- /dev/null +++ b/changesets-maven-plugin/src/it/release/.changeset/VERSIONS @@ -0,0 +1 @@ +my-package=2.5.0 diff --git a/changesets-maven-plugin/src/it/release/verify.groovy b/changesets-maven-plugin/src/it/release/verify.groovy index 4068103..0fdf57e 100644 --- a/changesets-maven-plugin/src/it/release/verify.groovy +++ b/changesets-maven-plugin/src/it/release/verify.groovy @@ -2,14 +2,8 @@ import groovy.xml.XmlSlurper import static org.assertj.core.api.Assertions.assertThat -String expectedVersion = '2.5.0'; +String expectedVersion = '2.5.0' -// The VERSION file should contain the correct version number -assertThat(new File(basedir, '.changeset/VERSION')) - .content() - .isEqualTo(expectedVersion) - -// The root pom version should have the same value as the VERSION file def project = new XmlSlurper().parse(new File(basedir, 'pom.xml')) assertThat(project.version).isEqualTo(expectedVersion) diff --git a/changesets-maven-plugin/src/main/java/se/fortnox/changesets/VersionFile.java b/changesets-maven-plugin/src/main/java/se/fortnox/changesets/VersionFile.java deleted file mode 100644 index 2f00f1f..0000000 --- a/changesets-maven-plugin/src/main/java/se/fortnox/changesets/VersionFile.java +++ /dev/null @@ -1,45 +0,0 @@ -package se.fortnox.changesets; - -import org.apache.maven.project.MavenProject; -import org.codehaus.plexus.logging.Logger; - -import javax.inject.Inject; -import javax.inject.Singleton; - -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.StandardOpenOption; -import java.util.Optional; - -import static se.fortnox.changesets.ChangesetWriter.CHANGESET_DIR; - -@Singleton -public class VersionFile { - private final Path versionFile; - - @Inject - public VersionFile(MavenProject project, Logger log) { - this.versionFile = project.getBasedir().toPath().resolve(CHANGESET_DIR).resolve("VERSION").toAbsolutePath(); - log.info("Using version file " + versionFile); - } - - public Optional currentVersion() { - if(!Files.exists(versionFile)) { - return Optional.empty(); - } - try { - return Optional.of(Files.readString(versionFile)); - } catch (IOException e) { - return Optional.empty(); - } - } - - public void assignVersion(String newVersion) { - try { - Files.writeString(versionFile, newVersion, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING); - } catch (IOException e) { - throw new RuntimeException(e); - } - } -} diff --git a/changesets-maven-plugin/src/main/java/se/fortnox/changesets/VersionsFile.java b/changesets-maven-plugin/src/main/java/se/fortnox/changesets/VersionsFile.java new file mode 100644 index 0000000..b0f53d8 --- /dev/null +++ b/changesets-maven-plugin/src/main/java/se/fortnox/changesets/VersionsFile.java @@ -0,0 +1,65 @@ +package se.fortnox.changesets; + +import java.io.BufferedReader; +import java.io.BufferedWriter; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Optional; +import java.util.Properties; +import java.util.TreeMap; + +import static se.fortnox.changesets.ChangesetWriter.CHANGESET_DIR; + +/** + * Read/write helper for {@code .changeset/VERSIONS} — the prepare→release handoff file + * keyed by artifactId. Only modules that bumped in a release are present. + */ +public class VersionsFile { + public static final String FILE = "VERSIONS"; + + public static Path locate(Path reactorRoot) { + return reactorRoot.resolve(CHANGESET_DIR).resolve(FILE); + } + + public static Map read(Path reactorRoot) { + Path file = locate(reactorRoot); + if (!Files.exists(file)) { + return Map.of(); + } + Properties props = new Properties(); + try (BufferedReader r = Files.newBufferedReader(file)) { + props.load(r); + } catch (IOException e) { + throw new RuntimeException("Failed to read " + file, e); + } + Map out = new LinkedHashMap<>(); + for (String key : props.stringPropertyNames()) { + out.put(key, props.getProperty(key)); + } + return out; + } + + public static Optional lookup(Path reactorRoot, String artifactId) { + return Optional.ofNullable(read(reactorRoot).get(artifactId)); + } + + public static void write(Path reactorRoot, Map versions) { + Path file = locate(reactorRoot); + try { + Files.createDirectories(file.getParent()); + try (BufferedWriter w = Files.newBufferedWriter(file)) { + for (Map.Entry e : new TreeMap<>(versions).entrySet()) { + w.write(e.getKey()); + w.write('='); + w.write(e.getValue()); + w.newLine(); + } + } + } catch (IOException e) { + throw new RuntimeException("Failed to write " + file, e); + } + } +} diff --git a/changesets-maven-plugin/src/main/java/se/fortnox/changesets/maven/PrepareMojo.java b/changesets-maven-plugin/src/main/java/se/fortnox/changesets/maven/PrepareMojo.java index d4a7f47..a970304 100644 --- a/changesets-maven-plugin/src/main/java/se/fortnox/changesets/maven/PrepareMojo.java +++ b/changesets-maven-plugin/src/main/java/se/fortnox/changesets/maven/PrepareMojo.java @@ -1,95 +1,164 @@ package se.fortnox.changesets.maven; +import org.apache.maven.execution.MavenSession; import org.apache.maven.plugin.AbstractMojo; import org.apache.maven.plugins.annotations.LifecyclePhase; import org.apache.maven.plugins.annotations.Mojo; import org.apache.maven.plugins.annotations.Parameter; import org.apache.maven.project.MavenProject; import org.codehaus.plexus.logging.Logger; -import org.semver4j.Semver; +import se.fortnox.changesets.BumpPlanner; +import se.fortnox.changesets.BumpPlanner.ModuleBump; import se.fortnox.changesets.ChangelogAggregator; +import se.fortnox.changesets.ChangelogAggregator.ReleaseEntry; import se.fortnox.changesets.Changeset; import se.fortnox.changesets.ChangesetLocator; +import se.fortnox.changesets.ChangesetsConfig; import se.fortnox.changesets.VersionCalculator; -import se.fortnox.changesets.VersionFile; +import se.fortnox.changesets.VersionsFile; import javax.inject.Inject; import java.io.File; import java.nio.file.Path; +import java.util.LinkedHashMap; import java.util.List; -import java.util.Optional; +import java.util.Map; + +import static se.fortnox.changesets.ChangesetWriter.CHANGESET_DIR; /** - * Applies all changesets into the changelog and calculates the new version number. + * Applies all changesets into the changelog, calculates new versions per module, and updates + * submodule poms. Runs once at the reactor root. *

- * This would normally be the last step in preparing a release PR. + * Versioning strategy is read from {@code .changeset/config.json}; defaults to {@code fixed}. */ -@Mojo(name = "prepare", defaultPhase = LifecyclePhase.INITIALIZE) +@Mojo(name = "prepare", defaultPhase = LifecyclePhase.INITIALIZE, aggregator = true) public class PrepareMojo extends AbstractMojo { - private final org.apache.maven.project.MavenProject project; - private final VersionFile versionFile; + private final MavenProject project; + private final MavenSession session; private final Logger logger; @Inject - public PrepareMojo(MavenProject project, VersionFile versionFile, Logger logger) { + public PrepareMojo(MavenProject project, MavenSession session, Logger logger) { this.project = project; - this.versionFile = versionFile; + this.session = session; this.logger = logger; } - /* + /** * Set to true in order to just process changeset files, avoiding any changes to the POM(s). */ @Parameter(property = "useReleasePluginIntegration", defaultValue = "false") protected boolean useReleasePluginIntegration = false; public void execute() { - Path baseDir = project.getBasedir().toPath(); - String packageName = project.getArtifactId(); + Path reactorRoot = project.getBasedir().toPath(); + Path changesetDir = reactorRoot.resolve(CHANGESET_DIR); - // Get all relevant changesets - ChangesetLocator changesetLocator = new ChangesetLocator(baseDir); - List changesets = changesetLocator.getChangesets(packageName); + ChangesetsConfig config = ChangesetsConfig.load(changesetDir); + logger.info("Versioning strategy: " + config.versioning()); + List changesets = new ChangesetLocator(reactorRoot).getAllChangesets(); if (changesets.isEmpty()) { - logger.info("No changesets for package: " + packageName + " found in " + baseDir); + logger.info("No changesets found in " + changesetDir); + return; + } + + Map reactor = collectReactorVersions(); + Map plan = BumpPlanner.plan(changesets, reactor, config); + + if (plan.isEmpty()) { + logger.info("No changesets matched any reactor module"); return; } - // Calculate new version - String currentVersion = versionFile.currentVersion().orElse("0.0.0");; - String newVersion = VersionCalculator.getNewVersion(currentVersion, changesets); - logger.info("Old version was " + currentVersion + ", will be updated to " + newVersion); + Map changedVersions = new LinkedHashMap<>(); + plan.values().stream() + .filter(ModuleBump::isVersionChange) + .forEach(bump -> changedVersions.put(bump.artifactId(), bump.newVersion())); - // Move changesets into CHANGELOG.md - ChangelogAggregator changelogAggregator = new ChangelogAggregator(baseDir); - changelogAggregator.mergeChangesetsToChangelog(packageName, newVersion); + if (!changedVersions.isEmpty()) { + VersionsFile.write(reactorRoot, changedVersions); + logger.info("Wrote " + VersionsFile.locate(reactorRoot) + " with " + changedVersions.size() + " entry/entries"); + } - // Advance to version deduced from changesets - versionFile.assignVersion(newVersion); + writeChangelog(reactorRoot, plan); - if(useReleasePluginIntegration) { + if (useReleasePluginIntegration) { logger.info("Changesets processed, but not updating POMs due to useReleasePluginIntegration being set to true."); return; } - // Set newVersion property to be used by versions:set - if (!newVersion.equals(currentVersion)) { - String pomVersion = Optional.ofNullable(Semver.coerce(newVersion)) - .map(semver -> semver.withIncPatch().withPreRelease("SNAPSHOT").getVersion()) - .orElseThrow(() -> new IllegalArgumentException("Cannot coerce \"%s\" into a semantic version.".formatted(currentVersion))); + applyPomVersions(plan); + } + private Map collectReactorVersions() { + Map reactor = new LinkedHashMap<>(); + for (MavenProject p : session.getProjects()) { + reactor.put(p.getArtifactId(), stripSnapshot(p.getVersion())); + } + return reactor; + } - logger.info("Updating " + project.getFile() + " to " + pomVersion); - PomUpdater.setProjectVersion(project.getFile(), pomVersion); + private static String stripSnapshot(String version) { + if (version == null) { + return "0.0.0"; + } + return version.endsWith("-SNAPSHOT") + ? version.substring(0, version.length() - "-SNAPSHOT".length()) + : version; + } - // Update submodules to reference the parent project with the new version - List modules = project.getModules(); - modules.forEach(module -> { - File modulePom = baseDir.resolve(module).resolve("pom.xml").toFile(); - logger.info("Updating submodule" + modulePom + " to " + pomVersion); - PomUpdater.setProjectParentVersion(modulePom, pomVersion); - }); + private void writeChangelog(Path reactorRoot, Map plan) { + Map entries = new LinkedHashMap<>(); + for (ModuleBump bump : plan.values()) { + entries.put(bump.artifactId(), new ReleaseEntry( + bump.artifactId(), + bump.newVersion(), + bump.changesets() + )); + } + new ChangelogAggregator(reactorRoot).mergeReleaseToChangelog(entries); + } + + private void applyPomVersions(Map plan) { + Map byArtifactId = new LinkedHashMap<>(); + for (MavenProject p : session.getProjects()) { + byArtifactId.put(p.getArtifactId(), p); + } + + String rootArtifactId = project.getArtifactId(); + String rootNewVersion = null; + + for (ModuleBump bump : plan.values()) { + if (!bump.isVersionChange()) { + continue; + } + MavenProject moduleProject = byArtifactId.get(bump.artifactId()); + if (moduleProject == null) { + continue; + } + String snapshotVersion = VersionCalculator.nextDevelopmentVersion(bump.newVersion()); + File pomFile = moduleProject.getFile(); + logger.info("Updating " + pomFile + " to " + snapshotVersion); + PomUpdater.setProjectVersion(pomFile, snapshotVersion); + + if (bump.artifactId().equals(rootArtifactId)) { + rootNewVersion = snapshotVersion; + } + } + + if (rootNewVersion != null) { + syncParentReferencesInSubmodules(rootNewVersion); + } + } + + private void syncParentReferencesInSubmodules(String rootSnapshotVersion) { + Path baseDir = project.getBasedir().toPath(); + for (String module : project.getModules()) { + File modulePom = baseDir.resolve(module).resolve("pom.xml").toFile(); + logger.info("Updating submodule " + modulePom + " parent ref to " + rootSnapshotVersion); + PomUpdater.setProjectParentVersion(modulePom, rootSnapshotVersion); } } } diff --git a/changesets-maven-plugin/src/main/java/se/fortnox/changesets/maven/ReleaseMojo.java b/changesets-maven-plugin/src/main/java/se/fortnox/changesets/maven/ReleaseMojo.java index fdaf90a..1fccb80 100644 --- a/changesets-maven-plugin/src/main/java/se/fortnox/changesets/maven/ReleaseMojo.java +++ b/changesets-maven-plugin/src/main/java/se/fortnox/changesets/maven/ReleaseMojo.java @@ -1,43 +1,70 @@ package se.fortnox.changesets.maven; +import org.apache.maven.execution.MavenSession; import org.apache.maven.plugin.AbstractMojo; import org.apache.maven.plugins.annotations.LifecyclePhase; import org.apache.maven.plugins.annotations.Mojo; import org.apache.maven.project.MavenProject; import org.codehaus.plexus.logging.Logger; -import se.fortnox.changesets.VersionFile; +import se.fortnox.changesets.VersionsFile; import javax.inject.Inject; import java.io.File; import java.nio.file.Path; -import java.util.List; +import java.util.LinkedHashMap; +import java.util.Map; @Mojo(name = "release", defaultPhase = LifecyclePhase.INITIALIZE, aggregator = true) public class ReleaseMojo extends AbstractMojo { private final MavenProject project; - private final VersionFile versionFile; + private final MavenSession session; private final Logger log; @Inject - public ReleaseMojo(MavenProject project, VersionFile versionFile, Logger log) { + public ReleaseMojo(MavenProject project, MavenSession session, Logger log) { this.project = project; - this.versionFile = versionFile; + this.session = session; this.log = log; } public void execute() { - Path baseDir = project.getBasedir().toPath(); - String pomVersion = versionFile.currentVersion().orElse("0.0.0"); - - log.info("Updating " + project.getFile() + " to " + pomVersion); - PomUpdater.setProjectVersion(project.getFile(), pomVersion); - - // Update submodules to reference the parent project with the new version - List modules = project.getModules(); - modules.forEach(module -> { - File modulePom = baseDir.resolve(module).resolve("pom.xml").toFile(); - log.info("Updating submodule" + modulePom + " to " + pomVersion); - PomUpdater.setProjectParentVersion(modulePom, pomVersion); - }); + Path reactorRoot = project.getBasedir().toPath(); + Map versions = VersionsFile.read(reactorRoot); + if (versions.isEmpty()) { + log.info("No release versions found in " + VersionsFile.locate(reactorRoot)); + return; + } + + Map byArtifactId = new LinkedHashMap<>(); + for (MavenProject p : session.getProjects()) { + byArtifactId.put(p.getArtifactId(), p); + } + + String rootArtifactId = project.getArtifactId(); + String rootReleaseVersion = null; + + for (Map.Entry entry : versions.entrySet()) { + MavenProject moduleProject = byArtifactId.get(entry.getKey()); + if (moduleProject == null) { + log.warn("VERSIONS entry for unknown module: " + entry.getKey()); + continue; + } + File pomFile = moduleProject.getFile(); + log.info("Updating " + pomFile + " to " + entry.getValue()); + PomUpdater.setProjectVersion(pomFile, entry.getValue()); + + if (entry.getKey().equals(rootArtifactId)) { + rootReleaseVersion = entry.getValue(); + } + } + + if (rootReleaseVersion != null) { + Path baseDir = project.getBasedir().toPath(); + for (String module : project.getModules()) { + File modulePom = baseDir.resolve(module).resolve("pom.xml").toFile(); + log.info("Updating submodule " + modulePom + " parent ref to " + rootReleaseVersion); + PomUpdater.setProjectParentVersion(modulePom, rootReleaseVersion); + } + } } } diff --git a/changesets-maven-plugin/src/main/java/se/fortnox/changesets/maven/policy/ChangesetsVersionPolicy.java b/changesets-maven-plugin/src/main/java/se/fortnox/changesets/maven/policy/ChangesetsVersionPolicy.java index 9b4082c..f3478ff 100644 --- a/changesets-maven-plugin/src/main/java/se/fortnox/changesets/maven/policy/ChangesetsVersionPolicy.java +++ b/changesets-maven-plugin/src/main/java/se/fortnox/changesets/maven/policy/ChangesetsVersionPolicy.java @@ -9,20 +9,21 @@ import org.codehaus.plexus.component.annotations.Component; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import se.fortnox.changesets.VersionsFile; import javax.inject.Inject; -import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; +import java.util.Optional; +import static se.fortnox.changesets.ChangesetWriter.CHANGESET_DIR; import static se.fortnox.changesets.VersionCalculator.nextDevelopmentVersion; @Component(role = VersionPolicy.class, hint = "changesets", - description = "A VersionPolicy implementation that uses changesets-java to calculate the current and next version.") + description = "A VersionPolicy implementation that uses changesets-java to calculate the current and next version per Maven module.") public class ChangesetsVersionPolicy implements VersionPolicy { private final Logger logger = LoggerFactory.getLogger(getClass()); - public static final String CHANGESET_DIR = ".changeset"; private final MavenProject project; @@ -32,30 +33,47 @@ public ChangesetsVersionPolicy(MavenProject project) { } @Override - public VersionPolicyResult getReleaseVersion(VersionPolicyRequest versionPolicyRequest) throws PolicyException, VersionParseException { - Path versionFile = project.getBasedir().toPath().resolve(CHANGESET_DIR).resolve("VERSION"); - logger.info("Reading version from " + versionFile); - try { - String version = Files.readString(versionFile); - return new VersionPolicyResult() - .setVersion(version); - } catch (IOException e) { - throw new RuntimeException(e); - } + public VersionPolicyResult getReleaseVersion(VersionPolicyRequest request) throws PolicyException, VersionParseException { + String version = lookupOrCurrent(request); + return new VersionPolicyResult().setVersion(stripSnapshot(version)); + } + @Override + public VersionPolicyResult getDevelopmentVersion(VersionPolicyRequest request) throws PolicyException, VersionParseException { + String version = lookupOrCurrent(request); + return new VersionPolicyResult().setVersion(nextDevelopmentVersion(stripSnapshot(version))); + } + private String lookupOrCurrent(VersionPolicyRequest request) { + Optional reactorRoot = findReactorRoot(project.getBasedir().toPath()); + if (reactorRoot.isPresent()) { + Optional mapped = VersionsFile.lookup(reactorRoot.get(), project.getArtifactId()); + if (mapped.isPresent()) { + logger.info("Resolved release version for {} from VERSIONS: {}", project.getArtifactId(), mapped.get()); + return mapped.get(); + } + } + logger.info("No VERSIONS entry for {}; using current version {}", project.getArtifactId(), request.getVersion()); + return request.getVersion(); } - @Override - public VersionPolicyResult getDevelopmentVersion(VersionPolicyRequest versionPolicyRequest) throws PolicyException, VersionParseException { - Path versionFile = project.getBasedir().toPath().resolve(CHANGESET_DIR).resolve("VERSION"); - try { - String version = Files.readString(versionFile); - return new VersionPolicyResult() - .setVersion(nextDevelopmentVersion(version)); - } catch (IOException e) { - throw new RuntimeException(e); + private static Optional findReactorRoot(Path start) { + Path current = start.toAbsolutePath(); + while (current != null) { + if (Files.isDirectory(current.resolve(CHANGESET_DIR))) { + return Optional.of(current); + } + current = current.getParent(); } + return Optional.empty(); + } + private static String stripSnapshot(String version) { + if (version == null) { + return "0.0.0"; + } + return version.endsWith("-SNAPSHOT") + ? version.substring(0, version.length() - "-SNAPSHOT".length()) + : version; } } From c179fecd04de750334e27d8b67cf75935403ed4b Mon Sep 17 00:00:00 2001 From: Andreas Rilling Date: Mon, 29 Jun 2026 12:15:02 +0200 Subject: [PATCH 2/5] feat: add BOM support --- README.md | 37 ++++ .../se/fortnox/changesets/BumpPlanner.java | 68 ++++++- .../changesets/ChangelogAggregator.java | 98 +++++++++- .../fortnox/changesets/ChangesetsConfig.java | 23 ++- .../fortnox/changesets/BumpPlannerTest.java | 111 ++++++++++- .../changesets/ChangelogAggregatorTest.java | 96 ++++++++++ .../changesets/ChangesetsConfigTest.java | 35 +++- .../src/it/bom-skipBom/.changeset/bump-a.md | 5 + .../src/it/bom-skipBom/.changeset/bump-b.md | 5 + .../src/it/bom-skipBom/.changeset/config.json | 7 + .../src/it/bom-skipBom/EXPECTED_CHANGELOG.md | 14 ++ .../src/it/bom-skipBom/bom/pom.xml | 34 ++++ .../it/bom-skipBom/consumer-parent/pom.xml | 14 ++ .../src/it/bom-skipBom/invoker.properties | 1 + .../src/it/bom-skipBom/pom.xml | 22 +++ .../src/it/bom-skipBom/starter-a/pom.xml | 13 ++ .../src/it/bom-skipBom/starter-b/pom.xml | 13 ++ .../src/it/bom-skipBom/test.properties | 1 + .../src/it/bom-skipBom/verify.groovy | 33 ++++ .../it/bom-versioning/.changeset/bump-a.md | 5 + .../it/bom-versioning/.changeset/bump-b.md | 5 + .../it/bom-versioning/.changeset/config.json | 7 + .../it/bom-versioning/EXPECTED_CHANGELOG.md | 23 +++ .../src/it/bom-versioning/bom/pom.xml | 34 ++++ .../it/bom-versioning/consumer-parent/pom.xml | 14 ++ .../src/it/bom-versioning/invoker.properties | 1 + .../src/it/bom-versioning/pom.xml | 22 +++ .../src/it/bom-versioning/starter-a/pom.xml | 13 ++ .../src/it/bom-versioning/starter-b/pom.xml | 13 ++ .../src/it/bom-versioning/verify.groovy | 40 ++++ .../snapshot-versions/.changeset/config.json | 3 + .../snapshot-versions/.changeset/patch-a.md | 6 + .../it/snapshot-versions/invoker.properties | 1 + .../src/it/snapshot-versions/module-a/pom.xml | 12 ++ .../src/it/snapshot-versions/module-b/pom.xml | 12 ++ .../src/it/snapshot-versions/pom.xml | 20 ++ .../src/it/snapshot-versions/verify.groovy | 19 ++ .../fortnox/changesets/maven/BomResolver.java | 54 ++++++ .../fortnox/changesets/maven/PomUpdater.java | 17 ++ .../fortnox/changesets/maven/PrepareMojo.java | 177 ++++++++++++++---- .../fortnox/changesets/maven/ReleaseMojo.java | 59 ++++-- 41 files changed, 1124 insertions(+), 63 deletions(-) create mode 100644 changesets-maven-plugin/src/it/bom-skipBom/.changeset/bump-a.md create mode 100644 changesets-maven-plugin/src/it/bom-skipBom/.changeset/bump-b.md create mode 100644 changesets-maven-plugin/src/it/bom-skipBom/.changeset/config.json create mode 100644 changesets-maven-plugin/src/it/bom-skipBom/EXPECTED_CHANGELOG.md create mode 100644 changesets-maven-plugin/src/it/bom-skipBom/bom/pom.xml create mode 100644 changesets-maven-plugin/src/it/bom-skipBom/consumer-parent/pom.xml create mode 100644 changesets-maven-plugin/src/it/bom-skipBom/invoker.properties create mode 100644 changesets-maven-plugin/src/it/bom-skipBom/pom.xml create mode 100644 changesets-maven-plugin/src/it/bom-skipBom/starter-a/pom.xml create mode 100644 changesets-maven-plugin/src/it/bom-skipBom/starter-b/pom.xml create mode 100644 changesets-maven-plugin/src/it/bom-skipBom/test.properties create mode 100644 changesets-maven-plugin/src/it/bom-skipBom/verify.groovy create mode 100644 changesets-maven-plugin/src/it/bom-versioning/.changeset/bump-a.md create mode 100644 changesets-maven-plugin/src/it/bom-versioning/.changeset/bump-b.md create mode 100644 changesets-maven-plugin/src/it/bom-versioning/.changeset/config.json create mode 100644 changesets-maven-plugin/src/it/bom-versioning/EXPECTED_CHANGELOG.md create mode 100644 changesets-maven-plugin/src/it/bom-versioning/bom/pom.xml create mode 100644 changesets-maven-plugin/src/it/bom-versioning/consumer-parent/pom.xml create mode 100644 changesets-maven-plugin/src/it/bom-versioning/invoker.properties create mode 100644 changesets-maven-plugin/src/it/bom-versioning/pom.xml create mode 100644 changesets-maven-plugin/src/it/bom-versioning/starter-a/pom.xml create mode 100644 changesets-maven-plugin/src/it/bom-versioning/starter-b/pom.xml create mode 100644 changesets-maven-plugin/src/it/bom-versioning/verify.groovy create mode 100644 changesets-maven-plugin/src/it/snapshot-versions/.changeset/config.json create mode 100644 changesets-maven-plugin/src/it/snapshot-versions/.changeset/patch-a.md create mode 100644 changesets-maven-plugin/src/it/snapshot-versions/invoker.properties create mode 100644 changesets-maven-plugin/src/it/snapshot-versions/module-a/pom.xml create mode 100644 changesets-maven-plugin/src/it/snapshot-versions/module-b/pom.xml create mode 100644 changesets-maven-plugin/src/it/snapshot-versions/pom.xml create mode 100644 changesets-maven-plugin/src/it/snapshot-versions/verify.groovy create mode 100644 changesets-maven-plugin/src/main/java/se/fortnox/changesets/maven/BomResolver.java diff --git a/README.md b/README.md index 8630f2d..ec5d857 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,43 @@ For `independent` (or `linked` / `fixed` sub-groups) to actually update individu must declare its own `` in its `pom.xml`. Submodules that inherit `` 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 `` pins sibling modules' versions via +`` (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 + ``). Explicit changesets targeting the BOM still work — they combine with the synthesized level. +- **The BOM's ``** 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 ``; you don't + have to name properties by any convention. +- **`consumerParent`** (optional) is the module a consumer sets as their ``. It typically has no `` 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 `` + reference is updated when the BOM bumps. Validation: the artifactId must exist in the reactor, and if it declares its own + `` 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 diff --git a/changesets-java/src/main/java/se/fortnox/changesets/BumpPlanner.java b/changesets-java/src/main/java/se/fortnox/changesets/BumpPlanner.java index d6628f6..df3517f 100644 --- a/changesets-java/src/main/java/se/fortnox/changesets/BumpPlanner.java +++ b/changesets-java/src/main/java/se/fortnox/changesets/BumpPlanner.java @@ -1,10 +1,11 @@ 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.HashMap; +import java.util.EnumSet; import java.util.HashSet; import java.util.LinkedHashMap; import java.util.LinkedHashSet; @@ -51,9 +52,74 @@ public static Map plan( } 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: + *

    + *
  • Removes the consumer-parent from the plan (it inherits its version from the BOM).
  • + *
  • Synthesizes/merges a BOM bump at the max level of any tracked module bump, + * combined with any explicit BOM-targeted changesets.
  • + *
  • 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.
  • + *
+ */ + private static void applyBomPlan( + Map result, + List allChangesets, + Map reactor, + Bom bom + ) { + String bomModule = bom.module(); + String consumerParent = bom.consumerParent(); + + if (consumerParent != null) { + result.remove(consumerParent); + } + + EnumSet 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 bomExplicit = allChangesets.stream() + .filter(c -> c.packageName().equals(bomModule)) + .toList(); + + if (trackedLevels.isEmpty() && bomExplicit.isEmpty()) { + return; + } + + List 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 filterKnownModules(List changesets, Set known) { List kept = new ArrayList<>(changesets.size()); for (Changeset c : changesets) { diff --git a/changesets-java/src/main/java/se/fortnox/changesets/ChangelogAggregator.java b/changesets-java/src/main/java/se/fortnox/changesets/ChangelogAggregator.java index 4f57d2a..2e825a3 100644 --- a/changesets-java/src/main/java/se/fortnox/changesets/ChangelogAggregator.java +++ b/changesets-java/src/main/java/se/fortnox/changesets/ChangelogAggregator.java @@ -6,6 +6,7 @@ import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; +import java.util.ArrayList; import java.util.Arrays; import java.util.Comparator; import java.util.List; @@ -87,17 +88,30 @@ public Path mergeChangesetsToChangelog(String packageName, String version) { * @return Path to the written CHANGELOG.md, or the changeset dir if nothing was written */ public Path mergeReleaseToChangelog(Map moduleEntries) { + return mergeReleaseToChangelog(moduleEntries, null); + } + + /** + * BOM-aware variant. When {@code bomContext} is non-null, emits a single top-level + * release header (consumer-parent + BOM version) with each bumped module rendered + * as a nested sub-section. The BOM's section additionally lists its pinned-version + * updates. + */ + public Path mergeReleaseToChangelog(Map moduleEntries, BomContext bomContext) { Path changesetsDir = this.baseDir.resolve(CHANGESET_DIR); - List nonEmpty = moduleEntries.values().stream() - .filter(e -> !e.changesets().isEmpty()) + List renderable = moduleEntries.values().stream() + .filter(e -> !e.changesets().isEmpty() + || (bomContext != null && e.artifactId().equals(bomContext.bomArtifactId()))) .toList(); - if (nonEmpty.isEmpty()) { + if (renderable.isEmpty()) { LOG.info("No changesets to write to changelog in {}", this.baseDir); return changesetsDir; } - String changelog = generateMultiModuleChangelog(moduleEntries); + String changelog = bomContext == null + ? generateMultiModuleChangelog(moduleEntries) + : generateBomChangelog(moduleEntries, bomContext); Path changelogFile; try { @@ -107,12 +121,31 @@ public Path mergeReleaseToChangelog(Map moduleEntries) { return changesetsDir; } - deleteConsumedChangesets(nonEmpty); + deleteConsumedChangesets(moduleEntries.values().stream() + .filter(e -> !e.changesets().isEmpty()) + .toList()); return changelogFile; } public record ReleaseEntry(String artifactId, String newVersion, List changesets) {} + /** + * Context for BOM-aware changelog rendering. + * + * @param headerArtifactId The artifactId shown in the top-level {@code ##} release header + * (consumer-parent if configured, otherwise the BOM itself). + * @param headerVersion The version shown in the top-level header (the BOM's version). + * @param bomArtifactId The BOM's artifactId — its section gets the pinned-versions block. + * @param pinnedUpdates Ordered map of pinned module artifactId → new version, as + * applied to the BOM's {@code }. + */ + public record BomContext( + String headerArtifactId, + String headerVersion, + String bomArtifactId, + Map pinnedUpdates + ) {} + private void deleteConsumedChangesets(List entries) { entries.stream() .flatMap(e -> e.changesets().stream()) @@ -151,6 +184,61 @@ private String generateModuleSection(ReleaseEntry entry) { %s""".formatted(entry.artifactId(), entry.newVersion(), changes); } + private String generateBomChangelog(Map moduleEntries, BomContext bom) { + List sections = new ArrayList<>(); + for (ReleaseEntry entry : moduleEntries.values()) { + if (entry.artifactId().equals(bom.bomArtifactId())) { + continue; + } + if (entry.changesets().isEmpty()) { + continue; + } + sections.add(generateBomNestedSection(entry)); + } + + ReleaseEntry bomEntry = moduleEntries.get(bom.bomArtifactId()); + if (bomEntry != null) { + sections.add(generateBomOwnSection(bomEntry, bom)); + } + + String body = String.join("\n\n", sections); + + String markdown = """ + # Changelog + + ## %s@%s + + %s + """.formatted(bom.headerArtifactId(), bom.headerVersion(), body); + + return MarkdownFormatter.format(markdown); + } + + private String generateBomNestedSection(ReleaseEntry entry) { + String changes = renderChangesByLevel(entry.changesets(), "####"); + return """ + ### %s@%s + + %s""".formatted(entry.artifactId(), entry.newVersion(), changes); + } + + private String generateBomOwnSection(ReleaseEntry bomEntry, BomContext bom) { + StringBuilder section = new StringBuilder(); + section.append("### ").append(bomEntry.artifactId()).append('@').append(bomEntry.newVersion()).append("\n\n"); + + if (!bomEntry.changesets().isEmpty()) { + section.append(renderChangesByLevel(bomEntry.changesets(), "####")).append("\n\n"); + } + + if (!bom.pinnedUpdates().isEmpty()) { + section.append("#### Pinned version updates\n\n"); + for (Map.Entry e : bom.pinnedUpdates().entrySet()) { + section.append("- ").append(e.getKey()).append('@').append(e.getValue()).append('\n'); + } + } + return section.toString(); + } + private String generateChangelog(String packageName, String version, List changesets) { String changes = renderChangesByLevel(changesets, "###"); diff --git a/changesets-java/src/main/java/se/fortnox/changesets/ChangesetsConfig.java b/changesets-java/src/main/java/se/fortnox/changesets/ChangesetsConfig.java index 2350a9a..1f300b5 100644 --- a/changesets-java/src/main/java/se/fortnox/changesets/ChangesetsConfig.java +++ b/changesets-java/src/main/java/se/fortnox/changesets/ChangesetsConfig.java @@ -19,7 +19,8 @@ public record ChangesetsConfig( VersioningStrategy versioning, List> linked, List> fixed, - ChangelogMode changelog + ChangelogMode changelog, + Bom bom ) { private static final Logger LOG = getLogger(ChangesetsConfig.class); public static final String CONFIG_FILE = "config.json"; @@ -33,7 +34,7 @@ public record ChangesetsConfig( } public static ChangesetsConfig defaults() { - return new ChangesetsConfig(VersioningStrategy.FIXED, List.of(), List.of(), ChangelogMode.ROOT); + return new ChangesetsConfig(VersioningStrategy.FIXED, List.of(), List.of(), ChangelogMode.ROOT, null); } /** @@ -100,4 +101,22 @@ public static ChangelogMode fromString(String value) { }; } } + + /** + * Optional BOM (Bill of Materials) configuration. When set, the BOM module's + * {@code } that pin sibling module versions are rewritten on every + * prepare, and the BOM itself is auto-bumped at the max level of any tracked + * module's bump. An optional {@code consumerParent} provides the changelog header + * artifactId; it inherits its version from the BOM via Maven parent inheritance. + */ + public record Bom(String module, String consumerParent) { + public Bom { + if (module == null || module.isBlank()) { + throw new IllegalArgumentException("bom.module must be set"); + } + if (consumerParent != null && consumerParent.isBlank()) { + consumerParent = null; + } + } + } } diff --git a/changesets-java/src/test/java/se/fortnox/changesets/BumpPlannerTest.java b/changesets-java/src/test/java/se/fortnox/changesets/BumpPlannerTest.java index 82d1434..f40c68f 100644 --- a/changesets-java/src/test/java/se/fortnox/changesets/BumpPlannerTest.java +++ b/changesets-java/src/test/java/se/fortnox/changesets/BumpPlannerTest.java @@ -74,7 +74,7 @@ void modulesWithoutGroupsBumpIndependently() { var changes = List.of( changeset("m1", Level.MINOR), changeset("m2", Level.PATCH)); - var config = new ChangesetsConfig(INDEPENDENT, List.of(), List.of(), ROOT); + var config = new ChangesetsConfig(INDEPENDENT, List.of(), List.of(), ROOT, null); var result = BumpPlanner.plan(changes, reactor, config); @@ -86,7 +86,7 @@ void modulesWithoutGroupsBumpIndependently() { void modulesWithoutChangesetsAreOmitted() { var reactor = Map.of("m1", "1.0.0", "m2", "1.0.0"); var changes = List.of(changeset("m1", Level.PATCH)); - var config = new ChangesetsConfig(INDEPENDENT, List.of(), List.of(), ROOT); + var config = new ChangesetsConfig(INDEPENDENT, List.of(), List.of(), ROOT, null); var result = BumpPlanner.plan(changes, reactor, config); @@ -109,7 +109,8 @@ void npmDocsExample_pkgA_patch_pkgB_minor_bothAt1_0_0() { INDEPENDENT, List.of(List.of("pkg-a", "pkg-b")), List.of(), - ROOT); + ROOT, + null); var result = BumpPlanner.plan(changes, reactor, config); @@ -126,7 +127,8 @@ void onlyActiveMembersBumpInLinkedGroup() { INDEPENDENT, List.of(List.of("pkg-a", "pkg-b")), List.of(), - ROOT); + ROOT, + null); var result = BumpPlanner.plan(changes, reactor, config); @@ -142,7 +144,8 @@ void baseVersionIsMaxAcrossAllLinkedMembersIncludingInactive() { INDEPENDENT, List.of(List.of("pkg-a", "pkg-b")), List.of(), - ROOT); + ROOT, + null); var result = BumpPlanner.plan(changes, reactor, config); @@ -161,7 +164,8 @@ void allFixedMembersBumpWhenAnyHasChangeset() { INDEPENDENT, List.of(), List.of(List.of("pkg-a", "pkg-b")), - ROOT); + ROOT, + null); var result = BumpPlanner.plan(changes, reactor, config); @@ -184,13 +188,106 @@ void changesetForUnknownModuleIsIgnored() { } } + @Nested + class BomBumping { + @Test + void bomBumpsAtMaxLevelOfTrackedModules() { + var reactor = Map.of("bom", "1.0.0", "starter-a", "1.0.0", "starter-b", "1.0.0"); + var changes = List.of( + changeset("starter-a", Level.PATCH), + changeset("starter-b", Level.MINOR)); + var config = new ChangesetsConfig( + INDEPENDENT, + List.of(), + List.of(), + ROOT, + new ChangesetsConfig.Bom("bom", null)); + + var result = BumpPlanner.plan(changes, reactor, config); + + assertThat(result.get("bom").newVersion()).isEqualTo("1.1.0"); + assertThat(result.get("bom").changesets()).isEmpty(); + assertThat(result.get("starter-a").newVersion()).isEqualTo("1.0.1"); + assertThat(result.get("starter-b").newVersion()).isEqualTo("1.1.0"); + } + + @Test + void bomKeepsExplicitChangesets() { + var reactor = Map.of("bom", "1.0.0", "starter-a", "1.0.0"); + var changes = List.of( + changeset("starter-a", Level.PATCH), + changeset("bom", Level.MAJOR)); + var config = new ChangesetsConfig( + INDEPENDENT, + List.of(), + List.of(), + ROOT, + new ChangesetsConfig.Bom("bom", null)); + + var result = BumpPlanner.plan(changes, reactor, config); + + assertThat(result.get("bom").newVersion()).isEqualTo("2.0.0"); + assertThat(result.get("bom").changesets()).hasSize(1); + assertThat(result.get("bom").changesets().get(0).level()).isEqualTo(Level.MAJOR); + } + + @Test + void consumerParentIsRemovedFromPlan() { + var reactor = Map.of("bom", "1.0.0", "consumer-parent", "1.0.0", "starter-a", "1.0.0"); + var changes = List.of(changeset("starter-a", Level.PATCH)); + var config = new ChangesetsConfig( + INDEPENDENT, + List.of(), + List.of(), + ROOT, + new ChangesetsConfig.Bom("bom", "consumer-parent")); + + var result = BumpPlanner.plan(changes, reactor, config); + + assertThat(result).doesNotContainKey("consumer-parent"); + assertThat(result).containsKeys("bom", "starter-a"); + } + + @Test + void bomDoesNothingWhenNoTrackedModulesBumpAndNoExplicitChangesets() { + var reactor = Map.of("bom", "1.0.0", "starter-a", "1.0.0"); + var changes = List.of(); + var config = new ChangesetsConfig( + INDEPENDENT, + List.of(), + List.of(), + ROOT, + new ChangesetsConfig.Bom("bom", null)); + + var result = BumpPlanner.plan(changes, reactor, config); + + assertThat(result).isEmpty(); + } + + @Test + void dependencyOnlyChangesetDoesNotBumpBom() { + var reactor = Map.of("bom", "1.0.0", "starter-a", "1.0.0"); + var changes = List.of(changeset("starter-a", Level.DEPENDENCY)); + var config = new ChangesetsConfig( + INDEPENDENT, + List.of(), + List.of(), + ROOT, + new ChangesetsConfig.Bom("bom", null)); + + var result = BumpPlanner.plan(changes, reactor, config); + + assertThat(result).containsOnlyKeys("starter-a"); + } + } + @Nested class DependencyOnly { @Test void dependencyOnlyChangesetEmitsBumpWithUnchangedVersion() { var reactor = Map.of("m1", "1.0.0"); var changes = List.of(changeset("m1", Level.DEPENDENCY)); - var config = new ChangesetsConfig(INDEPENDENT, List.of(), List.of(), ROOT); + var config = new ChangesetsConfig(INDEPENDENT, List.of(), List.of(), ROOT, null); var result = BumpPlanner.plan(changes, reactor, config); diff --git a/changesets-java/src/test/java/se/fortnox/changesets/ChangelogAggregatorTest.java b/changesets-java/src/test/java/se/fortnox/changesets/ChangelogAggregatorTest.java index c536f96..e526c29 100644 --- a/changesets-java/src/test/java/se/fortnox/changesets/ChangelogAggregatorTest.java +++ b/changesets-java/src/test/java/se/fortnox/changesets/ChangelogAggregatorTest.java @@ -385,6 +385,102 @@ void mergeReleaseToChangelog_prependsToExistingChangelog(@TempDir Path tempDir) """); } + @Test + void mergeReleaseToChangelog_bomMode_rendersNestedUnderConsumerParentHeader(@TempDir Path tempDir) throws Exception { + ChangesetWriter writer = new ChangesetWriter(tempDir); + Path aFile = writer.writeChangeset("starter-a", Level.MINOR, "Added feature A"); + Path bFile = writer.writeChangeset("starter-b", Level.PATCH, "Fixed B"); + + Changeset aChange = new Changeset("starter-a", Level.MINOR, "Added feature A", aFile.toFile()); + Changeset bChange = new Changeset("starter-b", Level.PATCH, "Fixed B", bFile.toFile()); + + Map entries = new LinkedHashMap<>(); + entries.put("starter-a", new ChangelogAggregator.ReleaseEntry("starter-a", "0.4.0", List.of(aChange))); + entries.put("starter-b", new ChangelogAggregator.ReleaseEntry("starter-b", "0.3.3", List.of(bChange))); + entries.put("bom-dep", new ChangelogAggregator.ReleaseEntry("bom-dep", "0.3.3", List.of())); + + Map pinned = new LinkedHashMap<>(); + pinned.put("starter-a", "0.4.0"); + pinned.put("starter-b", "0.3.3"); + var bomCtx = new ChangelogAggregator.BomContext("consumer-parent", "0.3.3", "bom-dep", pinned); + + new ChangelogAggregator(tempDir).mergeReleaseToChangelog(entries, bomCtx); + + assertThat(tempDir.resolve(CHANGELOG_FILE)) + .exists() + .content() + .isEqualTo(""" + # Changelog + + ## consumer-parent@0.3.3 + + ### starter-a@0.4.0 + + #### Minor Changes + + - Added feature A + + ### starter-b@0.3.3 + + #### Patch Changes + + - Fixed B + + ### bom-dep@0.3.3 + + #### Pinned version updates + + - starter-a@0.4.0 + - starter-b@0.3.3 + + """); + } + + @Test + void mergeReleaseToChangelog_bomMode_includesExplicitBomChangesetsAboveSyncedVersions(@TempDir Path tempDir) throws Exception { + ChangesetWriter writer = new ChangesetWriter(tempDir); + Path aFile = writer.writeChangeset("starter-a", Level.PATCH, "Fixed A"); + Path bomFile = writer.writeChangeset("bom-dep", Level.MAJOR, "Removed deprecated managed dep"); + + Changeset aChange = new Changeset("starter-a", Level.PATCH, "Fixed A", aFile.toFile()); + Changeset bomChange = new Changeset("bom-dep", Level.MAJOR, "Removed deprecated managed dep", bomFile.toFile()); + + Map entries = new LinkedHashMap<>(); + entries.put("starter-a", new ChangelogAggregator.ReleaseEntry("starter-a", "0.4.1", List.of(aChange))); + entries.put("bom-dep", new ChangelogAggregator.ReleaseEntry("bom-dep", "1.0.0", List.of(bomChange))); + + Map pinned = new LinkedHashMap<>(); + pinned.put("starter-a", "0.4.1"); + var bomCtx = new ChangelogAggregator.BomContext("bom-dep", "1.0.0", "bom-dep", pinned); + + new ChangelogAggregator(tempDir).mergeReleaseToChangelog(entries, bomCtx); + + assertThat(tempDir.resolve(CHANGELOG_FILE)) + .content() + .isEqualTo(""" + # Changelog + + ## bom-dep@1.0.0 + + ### starter-a@0.4.1 + + #### Patch Changes + + - Fixed A + + ### bom-dep@1.0.0 + + #### Major Changes + + - Removed deprecated managed dep + + #### Pinned version updates + + - starter-a@0.4.1 + + """); + } + @Test void mergeReleaseToChangelog_skipsModulesWithNoChangesets(@TempDir Path tempDir) { Map entries = new LinkedHashMap<>(); diff --git a/changesets-java/src/test/java/se/fortnox/changesets/ChangesetsConfigTest.java b/changesets-java/src/test/java/se/fortnox/changesets/ChangesetsConfigTest.java index 7599373..1c78900 100644 --- a/changesets-java/src/test/java/se/fortnox/changesets/ChangesetsConfigTest.java +++ b/changesets-java/src/test/java/se/fortnox/changesets/ChangesetsConfigTest.java @@ -31,12 +31,13 @@ void defaultsToFixedVersioningAndRootChangelog() { @Test void canonicalConstructorFillsNullsWithDefaults() { - var config = new ChangesetsConfig(null, null, null, null); + var config = new ChangesetsConfig(null, null, null, null, null); assertThat(config.versioning()).isEqualTo(FIXED); assertThat(config.linked()).isEmpty(); assertThat(config.fixed()).isEmpty(); assertThat(config.changelog()).isEqualTo(ROOT); + assertThat(config.bom()).isNull(); } } @@ -59,7 +60,11 @@ void parsesFullyPopulatedConfig() throws IOException { "versioning": "independent", "linked": [["pkg-a", "pkg-b"]], "fixed": [["pkg-c", "pkg-d"]], - "changelog": "root" + "changelog": "root", + "bom": { + "module": "pkg-bom", + "consumerParent": "pkg-parent" + } } """); @@ -69,6 +74,23 @@ void parsesFullyPopulatedConfig() throws IOException { assertThat(config.linked()).containsExactly(List.of("pkg-a", "pkg-b")); assertThat(config.fixed()).containsExactly(List.of("pkg-c", "pkg-d")); assertThat(config.changelog()).isEqualTo(ROOT); + assertThat(config.bom().module()).isEqualTo("pkg-bom"); + assertThat(config.bom().consumerParent()).isEqualTo("pkg-parent"); + } + + @Test + void parsesBomWithoutConsumerParent() throws IOException { + Files.writeString(tempDir.resolve("config.json"), """ + { + "versioning": "independent", + "bom": { "module": "pkg-bom" } + } + """); + + var config = ChangesetsConfig.load(tempDir); + + assertThat(config.bom().module()).isEqualTo("pkg-bom"); + assertThat(config.bom().consumerParent()).isNull(); } @Test @@ -103,7 +125,8 @@ void rejectsModuleInTwoLinkedGroups() { INDEPENDENT, List.of(List.of("pkg-a", "pkg-b"), List.of("pkg-a", "pkg-c")), List.of(), - ROOT)) + ROOT, + null)) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("pkg-a"); } @@ -114,7 +137,8 @@ void rejectsModuleInBothLinkedAndFixed() { INDEPENDENT, List.of(List.of("pkg-a", "pkg-b")), List.of(List.of("pkg-a", "pkg-c")), - ROOT)) + ROOT, + null)) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("pkg-a"); } @@ -125,7 +149,8 @@ void allowsDistinctGroups() { INDEPENDENT, List.of(List.of("pkg-a", "pkg-b")), List.of(List.of("pkg-c", "pkg-d")), - ROOT); + ROOT, + null); assertThat(config.linked()).hasSize(1); assertThat(config.fixed()).hasSize(1); diff --git a/changesets-maven-plugin/src/it/bom-skipBom/.changeset/bump-a.md b/changesets-maven-plugin/src/it/bom-skipBom/.changeset/bump-a.md new file mode 100644 index 0000000..ba49566 --- /dev/null +++ b/changesets-maven-plugin/src/it/bom-skipBom/.changeset/bump-a.md @@ -0,0 +1,5 @@ +--- +"starter-a": minor +--- + +Added starter-a feature diff --git a/changesets-maven-plugin/src/it/bom-skipBom/.changeset/bump-b.md b/changesets-maven-plugin/src/it/bom-skipBom/.changeset/bump-b.md new file mode 100644 index 0000000..69bace5 --- /dev/null +++ b/changesets-maven-plugin/src/it/bom-skipBom/.changeset/bump-b.md @@ -0,0 +1,5 @@ +--- +"starter-b": patch +--- + +Tiny starter-b fix diff --git a/changesets-maven-plugin/src/it/bom-skipBom/.changeset/config.json b/changesets-maven-plugin/src/it/bom-skipBom/.changeset/config.json new file mode 100644 index 0000000..f6b560a --- /dev/null +++ b/changesets-maven-plugin/src/it/bom-skipBom/.changeset/config.json @@ -0,0 +1,7 @@ +{ + "versioning": "independent", + "bom": { + "module": "bom", + "consumerParent": "consumer-parent" + } +} diff --git a/changesets-maven-plugin/src/it/bom-skipBom/EXPECTED_CHANGELOG.md b/changesets-maven-plugin/src/it/bom-skipBom/EXPECTED_CHANGELOG.md new file mode 100644 index 0000000..e37b118 --- /dev/null +++ b/changesets-maven-plugin/src/it/bom-skipBom/EXPECTED_CHANGELOG.md @@ -0,0 +1,14 @@ +# Changelog + +## starter-a@2.1.0 + +### Minor Changes + +- Added starter-a feature + +## starter-b@3.0.1 + +### Patch Changes + +- Tiny starter-b fix + diff --git a/changesets-maven-plugin/src/it/bom-skipBom/bom/pom.xml b/changesets-maven-plugin/src/it/bom-skipBom/bom/pom.xml new file mode 100644 index 0000000..df5cba0 --- /dev/null +++ b/changesets-maven-plugin/src/it/bom-skipBom/bom/pom.xml @@ -0,0 +1,34 @@ + + + 4.0.0 + + se.fortnox.maven.it + bom-root + 1.0.0 + + + bom + 0.3.0 + pom + + + 2.0.0 + 3.0.0 + + + + + + se.fortnox.maven.it + starter-a + ${starter-a.version} + + + se.fortnox.maven.it + starter-b + ${starter-b.version} + + + + diff --git a/changesets-maven-plugin/src/it/bom-skipBom/consumer-parent/pom.xml b/changesets-maven-plugin/src/it/bom-skipBom/consumer-parent/pom.xml new file mode 100644 index 0000000..c359d9c --- /dev/null +++ b/changesets-maven-plugin/src/it/bom-skipBom/consumer-parent/pom.xml @@ -0,0 +1,14 @@ + + + 4.0.0 + + se.fortnox.maven.it + bom + 0.3.0 + ../bom + + + consumer-parent + pom + diff --git a/changesets-maven-plugin/src/it/bom-skipBom/invoker.properties b/changesets-maven-plugin/src/it/bom-skipBom/invoker.properties new file mode 100644 index 0000000..dce7e2c --- /dev/null +++ b/changesets-maven-plugin/src/it/bom-skipBom/invoker.properties @@ -0,0 +1 @@ +invoker.goals=${project.groupId}:${project.artifactId}:${project.version}:prepare diff --git a/changesets-maven-plugin/src/it/bom-skipBom/pom.xml b/changesets-maven-plugin/src/it/bom-skipBom/pom.xml new file mode 100644 index 0000000..b47fe92 --- /dev/null +++ b/changesets-maven-plugin/src/it/bom-skipBom/pom.xml @@ -0,0 +1,22 @@ + + + 4.0.0 + + se.fortnox.maven.it + bom-root + 1.0.0 + pom + + IT: BOM versioning with consumer-parent. + + + UTF-8 + + + bom + consumer-parent + starter-a + starter-b + + diff --git a/changesets-maven-plugin/src/it/bom-skipBom/starter-a/pom.xml b/changesets-maven-plugin/src/it/bom-skipBom/starter-a/pom.xml new file mode 100644 index 0000000..a556a80 --- /dev/null +++ b/changesets-maven-plugin/src/it/bom-skipBom/starter-a/pom.xml @@ -0,0 +1,13 @@ + + + 4.0.0 + + se.fortnox.maven.it + bom-root + 1.0.0 + + + starter-a + 2.0.0 + diff --git a/changesets-maven-plugin/src/it/bom-skipBom/starter-b/pom.xml b/changesets-maven-plugin/src/it/bom-skipBom/starter-b/pom.xml new file mode 100644 index 0000000..231eb5a --- /dev/null +++ b/changesets-maven-plugin/src/it/bom-skipBom/starter-b/pom.xml @@ -0,0 +1,13 @@ + + + 4.0.0 + + se.fortnox.maven.it + bom-root + 1.0.0 + + + starter-b + 3.0.0 + diff --git a/changesets-maven-plugin/src/it/bom-skipBom/test.properties b/changesets-maven-plugin/src/it/bom-skipBom/test.properties new file mode 100644 index 0000000..a6c0dba --- /dev/null +++ b/changesets-maven-plugin/src/it/bom-skipBom/test.properties @@ -0,0 +1 @@ +skipBom=true diff --git a/changesets-maven-plugin/src/it/bom-skipBom/verify.groovy b/changesets-maven-plugin/src/it/bom-skipBom/verify.groovy new file mode 100644 index 0000000..cccbf41 --- /dev/null +++ b/changesets-maven-plugin/src/it/bom-skipBom/verify.groovy @@ -0,0 +1,33 @@ +import groovy.xml.XmlSlurper + +import static org.assertj.core.api.Assertions.assertThat + +def versions = new File(basedir, '.changeset/VERSIONS').text +assertThat(versions).contains("starter-a=2.1.0") +assertThat(versions).contains("starter-b=3.0.1") +// BOM was NOT bumped under skipBom +assertThat(versions).doesNotContain("bom=") +assertThat(versions).doesNotContain("consumer-parent=") + +// BOM pom completely untouched: version stays, properties stay +def bom = new XmlSlurper().parse(new File(basedir, 'bom/pom.xml')) +assertThat(bom.version).isEqualTo('0.3.0') +assertThat(bom.properties.'starter-a.version'.text()).isEqualTo('2.0.0') +assertThat(bom.properties.'starter-b.version'.text()).isEqualTo('3.0.0') + +// Consumer-parent's parent ref also untouched (BOM didn't bump) +def consumerParent = new XmlSlurper().parse(new File(basedir, 'consumer-parent/pom.xml')) +assertThat(consumerParent.parent.version.text()).isEqualTo('0.3.0') + +// Starters bumped as in plain independent mode +def starterA = new XmlSlurper().parse(new File(basedir, 'starter-a/pom.xml')) +assertThat(starterA.version).isEqualTo('2.1.1-SNAPSHOT') + +def starterB = new XmlSlurper().parse(new File(basedir, 'starter-b/pom.xml')) +assertThat(starterB.version).isEqualTo('3.0.2-SNAPSHOT') + +// Changelog is plain per-module sections (no consumer-parent wrapper, no pinned-versions block) +assertThat(new File(basedir, 'CHANGELOG.md')) + .hasSameTextualContentAs(new File(basedir, 'EXPECTED_CHANGELOG.md')) + +true diff --git a/changesets-maven-plugin/src/it/bom-versioning/.changeset/bump-a.md b/changesets-maven-plugin/src/it/bom-versioning/.changeset/bump-a.md new file mode 100644 index 0000000..ba49566 --- /dev/null +++ b/changesets-maven-plugin/src/it/bom-versioning/.changeset/bump-a.md @@ -0,0 +1,5 @@ +--- +"starter-a": minor +--- + +Added starter-a feature diff --git a/changesets-maven-plugin/src/it/bom-versioning/.changeset/bump-b.md b/changesets-maven-plugin/src/it/bom-versioning/.changeset/bump-b.md new file mode 100644 index 0000000..69bace5 --- /dev/null +++ b/changesets-maven-plugin/src/it/bom-versioning/.changeset/bump-b.md @@ -0,0 +1,5 @@ +--- +"starter-b": patch +--- + +Tiny starter-b fix diff --git a/changesets-maven-plugin/src/it/bom-versioning/.changeset/config.json b/changesets-maven-plugin/src/it/bom-versioning/.changeset/config.json new file mode 100644 index 0000000..f6b560a --- /dev/null +++ b/changesets-maven-plugin/src/it/bom-versioning/.changeset/config.json @@ -0,0 +1,7 @@ +{ + "versioning": "independent", + "bom": { + "module": "bom", + "consumerParent": "consumer-parent" + } +} diff --git a/changesets-maven-plugin/src/it/bom-versioning/EXPECTED_CHANGELOG.md b/changesets-maven-plugin/src/it/bom-versioning/EXPECTED_CHANGELOG.md new file mode 100644 index 0000000..069ec2d --- /dev/null +++ b/changesets-maven-plugin/src/it/bom-versioning/EXPECTED_CHANGELOG.md @@ -0,0 +1,23 @@ +# Changelog + +## consumer-parent@0.4.0 + +### starter-a@2.1.0 + +#### Minor Changes + +- Added starter-a feature + +### starter-b@3.0.1 + +#### Patch Changes + +- Tiny starter-b fix + +### bom@0.4.0 + +#### Pinned version updates + +- starter-a@2.1.0 +- starter-b@3.0.1 + diff --git a/changesets-maven-plugin/src/it/bom-versioning/bom/pom.xml b/changesets-maven-plugin/src/it/bom-versioning/bom/pom.xml new file mode 100644 index 0000000..df5cba0 --- /dev/null +++ b/changesets-maven-plugin/src/it/bom-versioning/bom/pom.xml @@ -0,0 +1,34 @@ + + + 4.0.0 + + se.fortnox.maven.it + bom-root + 1.0.0 + + + bom + 0.3.0 + pom + + + 2.0.0 + 3.0.0 + + + + + + se.fortnox.maven.it + starter-a + ${starter-a.version} + + + se.fortnox.maven.it + starter-b + ${starter-b.version} + + + + diff --git a/changesets-maven-plugin/src/it/bom-versioning/consumer-parent/pom.xml b/changesets-maven-plugin/src/it/bom-versioning/consumer-parent/pom.xml new file mode 100644 index 0000000..c359d9c --- /dev/null +++ b/changesets-maven-plugin/src/it/bom-versioning/consumer-parent/pom.xml @@ -0,0 +1,14 @@ + + + 4.0.0 + + se.fortnox.maven.it + bom + 0.3.0 + ../bom + + + consumer-parent + pom + diff --git a/changesets-maven-plugin/src/it/bom-versioning/invoker.properties b/changesets-maven-plugin/src/it/bom-versioning/invoker.properties new file mode 100644 index 0000000..dce7e2c --- /dev/null +++ b/changesets-maven-plugin/src/it/bom-versioning/invoker.properties @@ -0,0 +1 @@ +invoker.goals=${project.groupId}:${project.artifactId}:${project.version}:prepare diff --git a/changesets-maven-plugin/src/it/bom-versioning/pom.xml b/changesets-maven-plugin/src/it/bom-versioning/pom.xml new file mode 100644 index 0000000..b47fe92 --- /dev/null +++ b/changesets-maven-plugin/src/it/bom-versioning/pom.xml @@ -0,0 +1,22 @@ + + + 4.0.0 + + se.fortnox.maven.it + bom-root + 1.0.0 + pom + + IT: BOM versioning with consumer-parent. + + + UTF-8 + + + bom + consumer-parent + starter-a + starter-b + + diff --git a/changesets-maven-plugin/src/it/bom-versioning/starter-a/pom.xml b/changesets-maven-plugin/src/it/bom-versioning/starter-a/pom.xml new file mode 100644 index 0000000..a556a80 --- /dev/null +++ b/changesets-maven-plugin/src/it/bom-versioning/starter-a/pom.xml @@ -0,0 +1,13 @@ + + + 4.0.0 + + se.fortnox.maven.it + bom-root + 1.0.0 + + + starter-a + 2.0.0 + diff --git a/changesets-maven-plugin/src/it/bom-versioning/starter-b/pom.xml b/changesets-maven-plugin/src/it/bom-versioning/starter-b/pom.xml new file mode 100644 index 0000000..231eb5a --- /dev/null +++ b/changesets-maven-plugin/src/it/bom-versioning/starter-b/pom.xml @@ -0,0 +1,13 @@ + + + 4.0.0 + + se.fortnox.maven.it + bom-root + 1.0.0 + + + starter-b + 3.0.0 + diff --git a/changesets-maven-plugin/src/it/bom-versioning/verify.groovy b/changesets-maven-plugin/src/it/bom-versioning/verify.groovy new file mode 100644 index 0000000..6f852ba --- /dev/null +++ b/changesets-maven-plugin/src/it/bom-versioning/verify.groovy @@ -0,0 +1,40 @@ +import groovy.xml.XmlSlurper + +import static org.assertj.core.api.Assertions.assertThat + +def versions = new File(basedir, '.changeset/VERSIONS').text +assertThat(versions).contains("starter-a=2.1.0") +assertThat(versions).contains("starter-b=3.0.1") +assertThat(versions).contains("bom=0.4.0") +// Consumer parent and root are not in VERSIONS +assertThat(versions).doesNotContain("consumer-parent=") +assertThat(versions).doesNotContain("bom-root=") + +// Root version unchanged +def rootProject = new XmlSlurper().parse(new File(basedir, 'pom.xml')) +assertThat(rootProject.version).isEqualTo('1.0.0') + +// BOM bumped to next-dev SNAPSHOT +def bom = new XmlSlurper().parse(new File(basedir, 'bom/pom.xml')) +assertThat(bom.version).isEqualTo('0.4.1-SNAPSHOT') +// BOM properties rewritten to the starters' next-dev SNAPSHOTs +assertThat(bom.properties.'starter-a.version'.text()).isEqualTo('2.1.1-SNAPSHOT') +assertThat(bom.properties.'starter-b.version'.text()).isEqualTo('3.0.2-SNAPSHOT') + +// Consumer-parent has no own , but its parent ref tracks the BOM +def consumerParent = new XmlSlurper().parse(new File(basedir, 'consumer-parent/pom.xml')) +assertThat(consumerParent.version.size()).isEqualTo(0) +assertThat(consumerParent.parent.artifactId.text()).isEqualTo('bom') +assertThat(consumerParent.parent.version.text()).isEqualTo('0.4.1-SNAPSHOT') + +// Starters bumped to their own next-dev SNAPSHOTs +def starterA = new XmlSlurper().parse(new File(basedir, 'starter-a/pom.xml')) +assertThat(starterA.version).isEqualTo('2.1.1-SNAPSHOT') + +def starterB = new XmlSlurper().parse(new File(basedir, 'starter-b/pom.xml')) +assertThat(starterB.version).isEqualTo('3.0.2-SNAPSHOT') + +assertThat(new File(basedir, 'CHANGELOG.md')) + .hasSameTextualContentAs(new File(basedir, 'EXPECTED_CHANGELOG.md')) + +true diff --git a/changesets-maven-plugin/src/it/snapshot-versions/.changeset/config.json b/changesets-maven-plugin/src/it/snapshot-versions/.changeset/config.json new file mode 100644 index 0000000..d19875b --- /dev/null +++ b/changesets-maven-plugin/src/it/snapshot-versions/.changeset/config.json @@ -0,0 +1,3 @@ +{ + "versioning": "independent" +} diff --git a/changesets-maven-plugin/src/it/snapshot-versions/.changeset/patch-a.md b/changesets-maven-plugin/src/it/snapshot-versions/.changeset/patch-a.md new file mode 100644 index 0000000..21be9dc --- /dev/null +++ b/changesets-maven-plugin/src/it/snapshot-versions/.changeset/patch-a.md @@ -0,0 +1,6 @@ +--- +"module-a": patch +"module-b": minor +--- + +Patched a thing in module-a, minor change in module-b diff --git a/changesets-maven-plugin/src/it/snapshot-versions/invoker.properties b/changesets-maven-plugin/src/it/snapshot-versions/invoker.properties new file mode 100644 index 0000000..dce7e2c --- /dev/null +++ b/changesets-maven-plugin/src/it/snapshot-versions/invoker.properties @@ -0,0 +1 @@ +invoker.goals=${project.groupId}:${project.artifactId}:${project.version}:prepare diff --git a/changesets-maven-plugin/src/it/snapshot-versions/module-a/pom.xml b/changesets-maven-plugin/src/it/snapshot-versions/module-a/pom.xml new file mode 100644 index 0000000..d9a2817 --- /dev/null +++ b/changesets-maven-plugin/src/it/snapshot-versions/module-a/pom.xml @@ -0,0 +1,12 @@ + + + 4.0.0 + module-a + 1.1.3-SNAPSHOT + + se.fortnox.maven.it + snapshot-root + 1.0.0-SNAPSHOT + + diff --git a/changesets-maven-plugin/src/it/snapshot-versions/module-b/pom.xml b/changesets-maven-plugin/src/it/snapshot-versions/module-b/pom.xml new file mode 100644 index 0000000..2229f4e --- /dev/null +++ b/changesets-maven-plugin/src/it/snapshot-versions/module-b/pom.xml @@ -0,0 +1,12 @@ + + + 4.0.0 + module-b + 2.5.3-SNAPSHOT + + se.fortnox.maven.it + snapshot-root + 1.0.0-SNAPSHOT + + diff --git a/changesets-maven-plugin/src/it/snapshot-versions/pom.xml b/changesets-maven-plugin/src/it/snapshot-versions/pom.xml new file mode 100644 index 0000000..fcde892 --- /dev/null +++ b/changesets-maven-plugin/src/it/snapshot-versions/pom.xml @@ -0,0 +1,20 @@ + + + 4.0.0 + + se.fortnox.maven.it + snapshot-root + 1.0.0-SNAPSHOT + pom + + IT: SNAPSHOT versions should be treated as the next release target — no double-bump. + + + UTF-8 + + + module-a + module-b + + diff --git a/changesets-maven-plugin/src/it/snapshot-versions/verify.groovy b/changesets-maven-plugin/src/it/snapshot-versions/verify.groovy new file mode 100644 index 0000000..47e54ce --- /dev/null +++ b/changesets-maven-plugin/src/it/snapshot-versions/verify.groovy @@ -0,0 +1,19 @@ +import groovy.xml.XmlSlurper + +import static org.assertj.core.api.Assertions.assertThat + +// SNAPSHOT pom = "next intended release". Patch confirms; minor/major escalate from non-boundary. +// module-a 1.1.3-SNAPSHOT + patch -> release 1.1.3 -> next dev 1.1.4-SNAPSHOT (no double-bump) +// module-b 2.5.3-SNAPSHOT + minor -> release 2.6.0 -> next dev 2.6.1-SNAPSHOT (escalates) + +def versions = new File(basedir, '.changeset/VERSIONS').text +assertThat(versions).contains("module-a=1.1.3") +assertThat(versions).contains("module-b=2.6.0") + +def moduleA = new XmlSlurper().parse(new File(basedir, 'module-a/pom.xml')) +assertThat(moduleA.version).isEqualTo('1.1.4-SNAPSHOT') + +def moduleB = new XmlSlurper().parse(new File(basedir, 'module-b/pom.xml')) +assertThat(moduleB.version).isEqualTo('2.6.1-SNAPSHOT') + +true diff --git a/changesets-maven-plugin/src/main/java/se/fortnox/changesets/maven/BomResolver.java b/changesets-maven-plugin/src/main/java/se/fortnox/changesets/maven/BomResolver.java new file mode 100644 index 0000000..eba6243 --- /dev/null +++ b/changesets-maven-plugin/src/main/java/se/fortnox/changesets/maven/BomResolver.java @@ -0,0 +1,54 @@ +package se.fortnox.changesets.maven; + +import org.apache.maven.model.Dependency; +import org.apache.maven.model.DependencyManagement; +import org.apache.maven.model.Model; +import org.apache.maven.project.MavenProject; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Walks a BOM module's raw {@code } to map managed reactor + * artifacts to the property name that pins their version. Used to rewrite the + * BOM's {@code } when sibling modules bump. + */ +public class BomResolver { + private static final Pattern PROPERTY_REF = Pattern.compile("\\$\\{([^}]+)}"); + + /** + * @param bom The BOM Maven project (its original model is consulted). + * @param reactorIds The set of reactor module {@code groupId:artifactId} keys. + * @return Ordered map of reactor artifactId → property name in the BOM that pins it. + * Only entries whose {@code } is a property reference are included. + */ + public static Map resolvePinnedProperties(MavenProject bom, Map reactorIds) { + Map result = new LinkedHashMap<>(); + Model model = bom.getOriginalModel(); + if (model == null) { + return result; + } + DependencyManagement dm = model.getDependencyManagement(); + if (dm == null) { + return result; + } + for (Dependency dep : dm.getDependencies()) { + String key = dep.getGroupId() + ":" + dep.getArtifactId(); + String reactorArtifactId = reactorIds.get(key); + if (reactorArtifactId == null) { + continue; + } + String version = dep.getVersion(); + if (version == null) { + continue; + } + Matcher m = PROPERTY_REF.matcher(version); + if (m.matches()) { + result.put(reactorArtifactId, m.group(1)); + } + } + return result; + } +} diff --git a/changesets-maven-plugin/src/main/java/se/fortnox/changesets/maven/PomUpdater.java b/changesets-maven-plugin/src/main/java/se/fortnox/changesets/maven/PomUpdater.java index aa2ec01..421cf0e 100644 --- a/changesets-maven-plugin/src/main/java/se/fortnox/changesets/maven/PomUpdater.java +++ b/changesets-maven-plugin/src/main/java/se/fortnox/changesets/maven/PomUpdater.java @@ -39,6 +39,23 @@ public static void setProjectVersion(File outFile, String newVersion) { } + /** + * Set a property value in the {@code } section of a pom file. + * + * @param outFile The pom file to update + * @param property The property name + * @param value The new property value + */ + public static void setProperty(File outFile, String property, String value) { + updatePom(outFile, newPom -> { + try { + PomHelper.setPropertyVersion(newPom, null, property, value); + } catch (XMLStreamException e) { + LOG.error("Failed to update property {} in {}", property, outFile, e); + } + }); + } + /** * Set parent project version in a pom file * diff --git a/changesets-maven-plugin/src/main/java/se/fortnox/changesets/maven/PrepareMojo.java b/changesets-maven-plugin/src/main/java/se/fortnox/changesets/maven/PrepareMojo.java index a970304..b0eee3d 100644 --- a/changesets-maven-plugin/src/main/java/se/fortnox/changesets/maven/PrepareMojo.java +++ b/changesets-maven-plugin/src/main/java/se/fortnox/changesets/maven/PrepareMojo.java @@ -2,6 +2,7 @@ import org.apache.maven.execution.MavenSession; import org.apache.maven.plugin.AbstractMojo; +import org.apache.maven.plugin.MojoExecutionException; import org.apache.maven.plugins.annotations.LifecyclePhase; import org.apache.maven.plugins.annotations.Mojo; import org.apache.maven.plugins.annotations.Parameter; @@ -10,10 +11,12 @@ import se.fortnox.changesets.BumpPlanner; import se.fortnox.changesets.BumpPlanner.ModuleBump; import se.fortnox.changesets.ChangelogAggregator; +import se.fortnox.changesets.ChangelogAggregator.BomContext; import se.fortnox.changesets.ChangelogAggregator.ReleaseEntry; import se.fortnox.changesets.Changeset; import se.fortnox.changesets.ChangesetLocator; import se.fortnox.changesets.ChangesetsConfig; +import se.fortnox.changesets.ChangesetsConfig.Bom; import se.fortnox.changesets.VersionCalculator; import se.fortnox.changesets.VersionsFile; @@ -51,13 +54,28 @@ public PrepareMojo(MavenProject project, MavenSession session, Logger logger) { @Parameter(property = "useReleasePluginIntegration", defaultValue = "false") protected boolean useReleasePluginIntegration = false; - public void execute() { + /** + * Per-invocation override for BOM behavior. When {@code true}, any {@code bom} + * configuration in {@code .changeset/config.json} is ignored for this run — + * the BOM is not auto-bumped, its {@code } are not rewritten, and + * the changelog is rendered in plain multi-module mode (no consumer-parent + * wrapper header). Use this when you want to release a starter or two without + * cutting a new BOM version. + */ + @Parameter(property = "skipBom", defaultValue = "false") + protected boolean skipBom = false; + + public void execute() throws MojoExecutionException { Path reactorRoot = project.getBasedir().toPath(); Path changesetDir = reactorRoot.resolve(CHANGESET_DIR); - ChangesetsConfig config = ChangesetsConfig.load(changesetDir); + ChangesetsConfig loadedConfig = ChangesetsConfig.load(changesetDir); + ChangesetsConfig config = applySkipBom(loadedConfig); logger.info("Versioning strategy: " + config.versioning()); + Map byArtifactId = collectProjectsByArtifactId(); + validateBomConfig(config.bom(), byArtifactId); + List changesets = new ChangesetLocator(reactorRoot).getAllChangesets(); if (changesets.isEmpty()) { logger.info("No changesets found in " + changesetDir); @@ -82,34 +100,70 @@ public void execute() { logger.info("Wrote " + VersionsFile.locate(reactorRoot) + " with " + changedVersions.size() + " entry/entries"); } - writeChangelog(reactorRoot, plan); + writeChangelog(reactorRoot, plan, config.bom(), byArtifactId); if (useReleasePluginIntegration) { logger.info("Changesets processed, but not updating POMs due to useReleasePluginIntegration being set to true."); return; } - applyPomVersions(plan); + Map snapshotVersions = applyPomVersions(plan, byArtifactId); + if (config.bom() != null) { + applyBomPropertyUpdates(config.bom(), plan, snapshotVersions, byArtifactId); + } } - private Map collectReactorVersions() { - Map reactor = new LinkedHashMap<>(); + private ChangesetsConfig applySkipBom(ChangesetsConfig config) { + if (!skipBom || config.bom() == null) { + return config; + } + logger.info("skipBom=true: ignoring BOM config '" + config.bom().module() + "' for this prepare run"); + return new ChangesetsConfig(config.versioning(), config.linked(), config.fixed(), config.changelog(), null); + } + + private Map collectProjectsByArtifactId() { + Map byArtifactId = new LinkedHashMap<>(); for (MavenProject p : session.getProjects()) { - reactor.put(p.getArtifactId(), stripSnapshot(p.getVersion())); + byArtifactId.put(p.getArtifactId(), p); } - return reactor; + return byArtifactId; } - private static String stripSnapshot(String version) { - if (version == null) { - return "0.0.0"; + private void validateBomConfig(Bom bom, Map byArtifactId) throws MojoExecutionException { + if (bom == null) { + return; + } + if (!byArtifactId.containsKey(bom.module())) { + throw new MojoExecutionException( + "bom.module '" + bom.module() + "' is not present in the reactor"); + } + if (bom.consumerParent() != null) { + MavenProject cp = byArtifactId.get(bom.consumerParent()); + if (cp == null) { + throw new MojoExecutionException( + "bom.consumerParent '" + bom.consumerParent() + "' is not present in the reactor"); + } + String ownVersion = cp.getOriginalModel() == null ? null : cp.getOriginalModel().getVersion(); + MavenProject bomProject = byArtifactId.get(bom.module()); + String bomVersion = bomProject.getOriginalModel() == null ? null : bomProject.getOriginalModel().getVersion(); + if (ownVersion != null && !ownVersion.equals(bomVersion)) { + throw new MojoExecutionException( + "bom.consumerParent '" + bom.consumerParent() + "' has its own (" + + ownVersion + ") different from the BOM's (" + bomVersion + "); " + + "consumer-parent must inherit its version from the BOM"); + } + } + } + + private Map collectReactorVersions() { + Map reactor = new LinkedHashMap<>(); + for (MavenProject p : session.getProjects()) { + reactor.put(p.getArtifactId(), p.getVersion() == null ? "0.0.0" : p.getVersion()); } - return version.endsWith("-SNAPSHOT") - ? version.substring(0, version.length() - "-SNAPSHOT".length()) - : version; + return reactor; } - private void writeChangelog(Path reactorRoot, Map plan) { + private void writeChangelog(Path reactorRoot, Map plan, Bom bom, Map byArtifactId) { Map entries = new LinkedHashMap<>(); for (ModuleBump bump : plan.values()) { entries.put(bump.artifactId(), new ReleaseEntry( @@ -118,17 +172,43 @@ private void writeChangelog(Path reactorRoot, Map plan) { bump.changesets() )); } - new ChangelogAggregator(reactorRoot).mergeReleaseToChangelog(entries); + + BomContext bomContext = null; + if (bom != null && plan.containsKey(bom.module())) { + ModuleBump bomBump = plan.get(bom.module()); + String headerArtifactId = bom.consumerParent() != null ? bom.consumerParent() : bom.module(); + Map pinnedUpdates = collectPinnedUpdates(bom, plan, byArtifactId); + bomContext = new BomContext(headerArtifactId, bomBump.newVersion(), bom.module(), pinnedUpdates); + } + + new ChangelogAggregator(reactorRoot).mergeReleaseToChangelog(entries, bomContext); } - private void applyPomVersions(Map plan) { - Map byArtifactId = new LinkedHashMap<>(); + private Map collectPinnedUpdates(Bom bom, Map plan, Map byArtifactId) { + MavenProject bomProject = byArtifactId.get(bom.module()); + Map reactorIdsByGav = new LinkedHashMap<>(); for (MavenProject p : session.getProjects()) { - byArtifactId.put(p.getArtifactId(), p); + reactorIdsByGav.put(p.getGroupId() + ":" + p.getArtifactId(), p.getArtifactId()); } + Map pinnedProps = BomResolver.resolvePinnedProperties(bomProject, reactorIdsByGav); - String rootArtifactId = project.getArtifactId(); - String rootNewVersion = null; + Map updates = new LinkedHashMap<>(); + for (String artifactId : pinnedProps.keySet()) { + ModuleBump bump = plan.get(artifactId); + if (bump != null && bump.isVersionChange()) { + updates.put(artifactId, bump.newVersion()); + } + } + return updates; + } + + /** + * Writes each bumped module's pom to its next development (snapshot) version, and + * keeps parent references in sync when their parent is also bumped. Returns the + * artifactId → snapshotVersion map for downstream use (e.g. BOM property rewrite). + */ + private Map applyPomVersions(Map plan, Map byArtifactId) { + Map snapshotVersions = new LinkedHashMap<>(); for (ModuleBump bump : plan.values()) { if (!bump.isVersionChange()) { @@ -139,26 +219,59 @@ private void applyPomVersions(Map plan) { continue; } String snapshotVersion = VersionCalculator.nextDevelopmentVersion(bump.newVersion()); + snapshotVersions.put(bump.artifactId(), snapshotVersion); File pomFile = moduleProject.getFile(); logger.info("Updating " + pomFile + " to " + snapshotVersion); PomUpdater.setProjectVersion(pomFile, snapshotVersion); + } - if (bump.artifactId().equals(rootArtifactId)) { - rootNewVersion = snapshotVersion; + syncParentReferences(snapshotVersions, byArtifactId); + return snapshotVersions; + } + + private void syncParentReferences(Map snapshotVersions, Map byArtifactId) { + for (MavenProject p : session.getProjects()) { + if (p.getOriginalModel() == null || p.getOriginalModel().getParent() == null) { + continue; } + String parentArtifactId = p.getOriginalModel().getParent().getArtifactId(); + String parentSnapshot = snapshotVersions.get(parentArtifactId); + if (parentSnapshot == null) { + continue; + } + File pomFile = p.getFile(); + logger.info("Updating " + pomFile + " parent ref to " + parentSnapshot); + PomUpdater.setProjectParentVersion(pomFile, parentSnapshot); } + } - if (rootNewVersion != null) { - syncParentReferencesInSubmodules(rootNewVersion); + private void applyBomPropertyUpdates( + Bom bom, + Map plan, + Map snapshotVersions, + Map byArtifactId + ) { + MavenProject bomProject = byArtifactId.get(bom.module()); + Map reactorIdsByGav = new LinkedHashMap<>(); + for (MavenProject p : session.getProjects()) { + reactorIdsByGav.put(p.getGroupId() + ":" + p.getArtifactId(), p.getArtifactId()); } - } + Map pinnedProps = BomResolver.resolvePinnedProperties(bomProject, reactorIdsByGav); - private void syncParentReferencesInSubmodules(String rootSnapshotVersion) { - Path baseDir = project.getBasedir().toPath(); - for (String module : project.getModules()) { - File modulePom = baseDir.resolve(module).resolve("pom.xml").toFile(); - logger.info("Updating submodule " + modulePom + " parent ref to " + rootSnapshotVersion); - PomUpdater.setProjectParentVersion(modulePom, rootSnapshotVersion); + File bomPom = bomProject.getFile(); + for (Map.Entry entry : pinnedProps.entrySet()) { + String artifactId = entry.getKey(); + String propertyName = entry.getValue(); + ModuleBump bump = plan.get(artifactId); + if (bump == null || !bump.isVersionChange()) { + continue; + } + String snapshotVersion = snapshotVersions.get(artifactId); + if (snapshotVersion == null) { + continue; + } + logger.info("Updating " + bomPom + " property " + propertyName + " to " + snapshotVersion); + PomUpdater.setProperty(bomPom, propertyName, snapshotVersion); } } } diff --git a/changesets-maven-plugin/src/main/java/se/fortnox/changesets/maven/ReleaseMojo.java b/changesets-maven-plugin/src/main/java/se/fortnox/changesets/maven/ReleaseMojo.java index 1fccb80..49adbe2 100644 --- a/changesets-maven-plugin/src/main/java/se/fortnox/changesets/maven/ReleaseMojo.java +++ b/changesets-maven-plugin/src/main/java/se/fortnox/changesets/maven/ReleaseMojo.java @@ -6,6 +6,8 @@ import org.apache.maven.plugins.annotations.Mojo; import org.apache.maven.project.MavenProject; import org.codehaus.plexus.logging.Logger; +import se.fortnox.changesets.ChangesetsConfig; +import se.fortnox.changesets.ChangesetsConfig.Bom; import se.fortnox.changesets.VersionsFile; import javax.inject.Inject; @@ -14,6 +16,8 @@ import java.util.LinkedHashMap; import java.util.Map; +import static se.fortnox.changesets.ChangesetWriter.CHANGESET_DIR; + @Mojo(name = "release", defaultPhase = LifecyclePhase.INITIALIZE, aggregator = true) public class ReleaseMojo extends AbstractMojo { private final MavenProject project; @@ -40,9 +44,6 @@ public void execute() { byArtifactId.put(p.getArtifactId(), p); } - String rootArtifactId = project.getArtifactId(); - String rootReleaseVersion = null; - for (Map.Entry entry : versions.entrySet()) { MavenProject moduleProject = byArtifactId.get(entry.getKey()); if (moduleProject == null) { @@ -52,19 +53,55 @@ public void execute() { File pomFile = moduleProject.getFile(); log.info("Updating " + pomFile + " to " + entry.getValue()); PomUpdater.setProjectVersion(pomFile, entry.getValue()); + } - if (entry.getKey().equals(rootArtifactId)) { - rootReleaseVersion = entry.getValue(); + syncParentReferences(versions, byArtifactId); + + ChangesetsConfig config = ChangesetsConfig.load(reactorRoot.resolve(CHANGESET_DIR)); + if (config.bom() != null && versions.containsKey(config.bom().module())) { + applyBomPropertyUpdates(config.bom(), versions, byArtifactId); + } else if (config.bom() != null) { + log.info("BOM '" + config.bom().module() + "' not in VERSIONS — skipping BOM property updates"); + } + } + + private void syncParentReferences(Map versions, Map byArtifactId) { + for (MavenProject p : session.getProjects()) { + if (p.getOriginalModel() == null || p.getOriginalModel().getParent() == null) { + continue; } + String parentArtifactId = p.getOriginalModel().getParent().getArtifactId(); + String parentReleaseVersion = versions.get(parentArtifactId); + if (parentReleaseVersion == null) { + continue; + } + File pomFile = p.getFile(); + log.info("Updating " + pomFile + " parent ref to " + parentReleaseVersion); + PomUpdater.setProjectParentVersion(pomFile, parentReleaseVersion); } + } - if (rootReleaseVersion != null) { - Path baseDir = project.getBasedir().toPath(); - for (String module : project.getModules()) { - File modulePom = baseDir.resolve(module).resolve("pom.xml").toFile(); - log.info("Updating submodule " + modulePom + " parent ref to " + rootReleaseVersion); - PomUpdater.setProjectParentVersion(modulePom, rootReleaseVersion); + private void applyBomPropertyUpdates(Bom bom, Map versions, Map byArtifactId) { + MavenProject bomProject = byArtifactId.get(bom.module()); + if (bomProject == null) { + return; + } + Map reactorIdsByGav = new LinkedHashMap<>(); + for (MavenProject p : session.getProjects()) { + reactorIdsByGav.put(p.getGroupId() + ":" + p.getArtifactId(), p.getArtifactId()); + } + Map pinnedProps = BomResolver.resolvePinnedProperties(bomProject, reactorIdsByGav); + + File bomPom = bomProject.getFile(); + for (Map.Entry entry : pinnedProps.entrySet()) { + String artifactId = entry.getKey(); + String propertyName = entry.getValue(); + String releaseVersion = versions.get(artifactId); + if (releaseVersion == null) { + continue; } + log.info("Updating " + bomPom + " property " + propertyName + " to " + releaseVersion); + PomUpdater.setProperty(bomPom, propertyName, releaseVersion); } } } From 293626fad0ec4469c5cdd1120a3eafaa89c2ef9f Mon Sep 17 00:00:00 2001 From: Andreas Rilling Date: Tue, 30 Jun 2026 20:23:45 +0200 Subject: [PATCH 3/5] chore: add changeset file --- .changeset/brave-otters-versioning.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/brave-otters-versioning.md diff --git a/.changeset/brave-otters-versioning.md b/.changeset/brave-otters-versioning.md new file mode 100644 index 0000000..37780ea --- /dev/null +++ b/.changeset/brave-otters-versioning.md @@ -0,0 +1,5 @@ +--- +"changesets": minor +--- + +Add support for the `individual` version strategy, allowing sub-modules in a multi-module Maven project to be versioned independently of each other. From 1c90f8e03307564aabb80ed264bf042a029fa089 Mon Sep 17 00:00:00 2001 From: Andreas Riling Date: Wed, 1 Jul 2026 12:02:46 +0200 Subject: [PATCH 4/5] fix: changeset has incorrect property name Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .changeset/brave-otters-versioning.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/brave-otters-versioning.md b/.changeset/brave-otters-versioning.md index 37780ea..62b26bb 100644 --- a/.changeset/brave-otters-versioning.md +++ b/.changeset/brave-otters-versioning.md @@ -2,4 +2,4 @@ "changesets": minor --- -Add support for the `individual` version strategy, allowing sub-modules in a multi-module Maven project to be versioned independently of each other. +Add support for the `independent` version strategy, allowing sub-modules in a multi-module Maven project to be versioned independently of each other. From d02dcd230e9fea1676d83d50f3dd52afed3051cd Mon Sep 17 00:00:00 2001 From: Andreas Rilling Date: Wed, 1 Jul 2026 12:09:23 +0200 Subject: [PATCH 5/5] fix: prevent version collision in highestVersion for equal numeric versions --- .../java/se/fortnox/changesets/BumpPlanner.java | 15 +++++++++------ .../se/fortnox/changesets/BumpPlannerTest.java | 10 ++++++++++ 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/changesets-java/src/main/java/se/fortnox/changesets/BumpPlanner.java b/changesets-java/src/main/java/se/fortnox/changesets/BumpPlanner.java index df3517f..7d25e3f 100644 --- a/changesets-java/src/main/java/se/fortnox/changesets/BumpPlanner.java +++ b/changesets-java/src/main/java/se/fortnox/changesets/BumpPlanner.java @@ -13,7 +13,6 @@ import java.util.Map; import java.util.Optional; import java.util.Set; -import java.util.TreeMap; import java.util.stream.Collectors; import static org.slf4j.LoggerFactory.getLogger; @@ -201,17 +200,21 @@ private static Map planGroup(Group group, List gr } private static String highestVersion(Collection versions) { - TreeMap sorted = new TreeMap<>(); + 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::withClearedPreReleaseAndBuild) + .map(org.semver4j.Semver::withClearedBuild) .orElseThrow(() -> new IllegalArgumentException("Cannot coerce \"%s\" into a semantic version.".formatted(v))); - sorted.put(semver, v); + if (maxSemver == null || semver.compareTo(maxSemver) > 0) { + maxSemver = semver; + maxRaw = v; + } } - if (sorted.isEmpty()) { + if (maxRaw == null) { throw new IllegalStateException("Group has no resolvable members in reactor"); } - return sorted.lastEntry().getValue(); + return maxRaw; } private enum GroupKind { FIXED, LINKED, INDIVIDUAL } diff --git a/changesets-java/src/test/java/se/fortnox/changesets/BumpPlannerTest.java b/changesets-java/src/test/java/se/fortnox/changesets/BumpPlannerTest.java index f40c68f..7b5e387 100644 --- a/changesets-java/src/test/java/se/fortnox/changesets/BumpPlannerTest.java +++ b/changesets-java/src/test/java/se/fortnox/changesets/BumpPlannerTest.java @@ -64,6 +64,16 @@ void baseVersionIsMaxOfReactorWhenVersionsDiffer() { assertThat(result.values()).allMatch(b -> b.newVersion().equals("2.0.1")); } + + @Test + void releaseVersionOutranksSnapshotAtSameNumericVersion() { + var reactor = Map.of("root", "1.0.0-SNAPSHOT", "m1", "1.0.0", "m2", "1.0.0-SNAPSHOT"); + var changes = List.of(changeset("m1", Level.PATCH)); + + var result = BumpPlanner.plan(changes, reactor, ChangesetsConfig.defaults()); + + assertThat(result.values()).allMatch(b -> b.newVersion().equals("1.0.1")); + } } @Nested