diff --git a/settings.gradle b/settings.gradle
index 101c44479d..48b547ef1c 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -53,6 +53,7 @@ include "spring-boot-project:spring-boot-tools:spring-boot-autoconfigure-process
include "spring-boot-project:spring-boot-tools:spring-boot-buildpack-platform"
include "spring-boot-project:spring-boot-tools:spring-boot-cli"
include "spring-boot-project:spring-boot-tools:spring-boot-configuration-metadata"
+include "spring-boot-project:spring-boot-tools:spring-boot-configuration-metadata-changelog-generator"
include "spring-boot-project:spring-boot-tools:spring-boot-configuration-processor"
include "spring-boot-project:spring-boot-tools:spring-boot-gradle-plugin"
include "spring-boot-project:spring-boot-tools:spring-boot-gradle-test-support"
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-configuration-metadata-changelog-generator/build.gradle b/spring-boot-project/spring-boot-tools/spring-boot-configuration-metadata-changelog-generator/build.gradle
new file mode 100644
index 0000000000..4d5c517e90
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-configuration-metadata-changelog-generator/build.gradle
@@ -0,0 +1,58 @@
+plugins {
+ id "java"
+ id "org.springframework.boot.conventions"
+}
+
+description = "Spring Boot Configuration Metadata Changelog Generator"
+
+configurations {
+ oldMetadata
+ newMetadata
+}
+
+dependencies {
+ implementation(enforcedPlatform(project(":spring-boot-project:spring-boot-dependencies")))
+ implementation(project(":spring-boot-project:spring-boot-tools:spring-boot-configuration-metadata"))
+
+ testImplementation(enforcedPlatform(project(":spring-boot-project:spring-boot-dependencies")))
+ testImplementation("org.assertj:assertj-core")
+ testImplementation("org.junit.jupiter:junit-jupiter")
+}
+
+if (project.hasProperty("oldVersion") && project.hasProperty("newVersion")) {
+ dependencies {
+ ["spring-boot",
+ "spring-boot-actuator",
+ "spring-boot-actuator-autoconfigure",
+ "spring-boot-autoconfigure",
+ "spring-boot-devtools",
+ "spring-boot-test-autoconfigure"].each {
+ oldMetadata("org.springframework.boot:$it:$oldVersion")
+ newMetadata("org.springframework.boot:$it:$newVersion")
+ }
+ }
+
+ def prepareOldMetadata = tasks.register("prepareOldMetadata", Sync) {
+ from(configurations.oldMetadata)
+ if (project.hasProperty("oldVersion")) {
+ destinationDir = project.file("build/configuration-metadata-diff/$oldVersion")
+ }
+ }
+
+ def prepareNewMetadata = tasks.register("prepareNewMetadata", Sync) {
+ from(configurations.newMetadata)
+ if (project.hasProperty("newVersion")) {
+ destinationDir = project.file("build/configuration-metadata-diff/$newVersion")
+ }
+ }
+
+ tasks.register("generate", JavaExec) {
+ inputs.files(prepareOldMetadata, prepareNewMetadata)
+ outputs.file(project.file("build/configuration-metadata-changelog.adoc"))
+ classpath = sourceSets.main.runtimeClasspath
+ mainClass = 'org.springframework.boot.configurationmetadata.changelog.ConfigurationMetadataChangelogGenerator'
+ if (project.hasProperty("oldVersion") && project.hasProperty("newVersion")) {
+ args = [project.file("build/configuration-metadata-diff/$oldVersion"), project.file("build/configuration-metadata-diff/$newVersion"), project.file("build/configuration-metadata-changelog.adoc")]
+ }
+ }
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-configuration-metadata-changelog-generator/src/main/java/org/springframework/boot/configurationmetadata/changelog/ConfigurationMetadataChangelogGenerator.java b/spring-boot-project/spring-boot-tools/spring-boot-configuration-metadata-changelog-generator/src/main/java/org/springframework/boot/configurationmetadata/changelog/ConfigurationMetadataChangelogGenerator.java
new file mode 100644
index 0000000000..2eb5b1244e
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-configuration-metadata-changelog-generator/src/main/java/org/springframework/boot/configurationmetadata/changelog/ConfigurationMetadataChangelogGenerator.java
@@ -0,0 +1,56 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.configurationmetadata.changelog;
+
+import java.io.File;
+import java.io.FileWriter;
+import java.io.IOException;
+
+/**
+ * Generates a configuration metadata changelog. Requires three arguments:
+ *
+ *
+ * - The path of a directory containing jar files from which the old metadata will be
+ * extracted
+ *
- The path of a directory containing jar files from which the new metadata will be
+ * extracted
+ *
- The path of a file to which the changelog will be written
+ *
+ *
+ * The name of each directory will be used to name the old and new metadata in the
+ * generated changelog
+ *
+ * @author Andy Wilkinson
+ */
+final class ConfigurationMetadataChangelogGenerator {
+
+ private ConfigurationMetadataChangelogGenerator() {
+
+ }
+
+ public static void main(String[] args) throws IOException {
+ ConfigurationMetadataDiff diff = ConfigurationMetadataDiff.of(
+ NamedConfigurationMetadataRepository.from(new File(args[0])),
+ NamedConfigurationMetadataRepository.from(new File(args[1])));
+ try (ConfigurationMetadataChangelogWriter writer = new ConfigurationMetadataChangelogWriter(
+ new FileWriter(new File(args[2])))) {
+ writer.write(diff);
+ }
+ System.out.println("\nConfiguration metadata changelog written to '" + args[2] + "'");
+ }
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-configuration-metadata-changelog-generator/src/main/java/org/springframework/boot/configurationmetadata/changelog/ConfigurationMetadataChangelogWriter.java b/spring-boot-project/spring-boot-tools/spring-boot-configuration-metadata-changelog-generator/src/main/java/org/springframework/boot/configurationmetadata/changelog/ConfigurationMetadataChangelogWriter.java
new file mode 100644
index 0000000000..b08b1667d3
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-configuration-metadata-changelog-generator/src/main/java/org/springframework/boot/configurationmetadata/changelog/ConfigurationMetadataChangelogWriter.java
@@ -0,0 +1,204 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.configurationmetadata.changelog;
+
+import java.io.PrintWriter;
+import java.io.Writer;
+import java.text.BreakIterator;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+import org.springframework.boot.configurationmetadata.ConfigurationMetadataProperty;
+import org.springframework.boot.configurationmetadata.Deprecation;
+import org.springframework.boot.configurationmetadata.changelog.ConfigurationMetadataDiff.Difference;
+import org.springframework.boot.configurationmetadata.changelog.ConfigurationMetadataDiff.Difference.Type;
+
+/**
+ * Writes a configuration metadata changelog from a {@link ConfigurationMetadataDiff}.
+ *
+ * @author Stephane Nicoll
+ * @author Andy Wilkinson
+ */
+class ConfigurationMetadataChangelogWriter implements AutoCloseable {
+
+ private final PrintWriter out;
+
+ ConfigurationMetadataChangelogWriter(Writer out) {
+ this.out = new PrintWriter(out);
+ }
+
+ void write(ConfigurationMetadataDiff diff) {
+ this.out.append(String.format("Configuration property changes between `%s` and " + "`%s`%n", diff.leftName(),
+ diff.rightName()));
+ this.out.append(System.lineSeparator());
+ this.out.append(String.format("== Deprecated in `%s`%n", diff.rightName()));
+ Map> differencesByType = differencesByType(diff);
+ writeDeprecatedProperties(differencesByType.get(Type.DEPRECATED));
+ this.out.append(System.lineSeparator());
+ this.out.append(String.format("== New in `%s`%n", diff.rightName()));
+ writeAddedProperties(differencesByType.get(Type.ADDED));
+ this.out.append(System.lineSeparator());
+ this.out.append(String.format("== Removed in `%s`%n", diff.rightName()));
+ writeRemovedProperties(differencesByType.get(Type.DELETED), differencesByType.get(Type.DEPRECATED));
+ }
+
+ private Map> differencesByType(ConfigurationMetadataDiff diff) {
+ Map> differencesByType = new HashMap<>();
+ for (Type type : Type.values()) {
+ differencesByType.put(type, new ArrayList<>());
+ }
+ for (Difference difference : diff.differences()) {
+ differencesByType.get(difference.type()).add(difference);
+ }
+ return differencesByType;
+ }
+
+ private void writeDeprecatedProperties(List differences) {
+ if (differences.isEmpty()) {
+ this.out.append(String.format("None.%n"));
+ }
+ else {
+ List properties = sortProperties(differences, Difference::right).stream()
+ .filter(this::isDeprecatedInRelease)
+ .collect(Collectors.toList());
+ this.out.append(String.format("|======================%n"));
+ this.out.append(String.format("|Key |Replacement |Reason%n"));
+ properties.forEach((diff) -> {
+ ConfigurationMetadataProperty property = diff.right();
+ writeDeprecatedProperty(property);
+ });
+ this.out.append(String.format("|======================%n"));
+ }
+ this.out.append(String.format("%n%n"));
+ }
+
+ private boolean isDeprecatedInRelease(Difference difference) {
+ return difference.right().getDeprecation() != null
+ && Deprecation.Level.ERROR != difference.right().getDeprecation().getLevel();
+ }
+
+ private void writeAddedProperties(List differences) {
+ if (differences.isEmpty()) {
+ this.out.append(String.format("None.%n"));
+ }
+ else {
+ List properties = sortProperties(differences, Difference::right);
+ this.out.append(String.format("|======================%n"));
+ this.out.append(String.format("|Key |Default value |Description%n"));
+ properties.forEach((diff) -> writeRegularProperty(diff.right()));
+ this.out.append(String.format("|======================%n"));
+ }
+ this.out.append(String.format("%n%n"));
+ }
+
+ private void writeRemovedProperties(List deleted, List deprecated) {
+ List removed = getRemovedProperties(deleted, deprecated);
+ if (removed.isEmpty()) {
+ this.out.append(String.format("None.%n"));
+ }
+ else {
+ this.out.append(String.format("|======================%n"));
+ this.out.append(String.format("|Key |Replacement |Reason%n"));
+ removed.forEach((property) -> writeDeprecatedProperty(
+ (property.right() != null) ? property.right() : property.left()));
+ this.out.append(String.format("|======================%n"));
+ }
+ }
+
+ private List getRemovedProperties(List deleted, List deprecated) {
+ List properties = new ArrayList<>(deleted);
+ properties.addAll(deprecated.stream().filter((p) -> !isDeprecatedInRelease(p)).collect(Collectors.toList()));
+ return sortProperties(properties,
+ (difference) -> (difference.left() != null) ? difference.left() : difference.right());
+ }
+
+ private void writeRegularProperty(ConfigurationMetadataProperty property) {
+ this.out.append("|`").append(property.getId()).append("` |");
+ if (property.getDefaultValue() != null) {
+ this.out.append("`").append(defaultValueToString(property.getDefaultValue())).append("`");
+ }
+ this.out.append(" |");
+ if (property.getDescription() != null) {
+ this.out.append(property.getShortDescription());
+ }
+ this.out.append(System.lineSeparator());
+ }
+
+ private void writeDeprecatedProperty(ConfigurationMetadataProperty property) {
+ Deprecation deprecation = (property.getDeprecation() != null) ? property.getDeprecation() : new Deprecation();
+ this.out.append("|`").append(property.getId()).append("` |");
+ if (deprecation.getReplacement() != null) {
+ this.out.append("`").append(deprecation.getReplacement()).append("`");
+ }
+ this.out.append(" |");
+ if (deprecation.getReason() != null) {
+ this.out.append(getFirstSentence(deprecation.getReason()));
+ }
+ this.out.append(System.lineSeparator());
+ }
+
+ private String getFirstSentence(String text) {
+ int dot = text.indexOf('.');
+ if (dot != -1) {
+ BreakIterator breakIterator = BreakIterator.getSentenceInstance(Locale.US);
+ breakIterator.setText(text);
+ String sentence = text.substring(breakIterator.first(), breakIterator.next()).trim();
+ return removeSpaceBetweenLine(sentence);
+ }
+ else {
+ String[] lines = text.split(System.lineSeparator());
+ return lines[0].trim();
+ }
+ }
+
+ private static String removeSpaceBetweenLine(String text) {
+ String[] lines = text.split(System.lineSeparator());
+ StringBuilder sb = new StringBuilder();
+ for (String line : lines) {
+ sb.append(line.trim()).append(" ");
+ }
+ return sb.toString().trim();
+ }
+
+ private List sortProperties(List properties,
+ Function property) {
+ List sorted = new ArrayList<>(properties);
+ sorted.sort((o1, o2) -> property.apply(o1).getId().compareTo(property.apply(o2).getId()));
+ return sorted;
+ }
+
+ private static String defaultValueToString(Object defaultValue) {
+ if (defaultValue instanceof Object[]) {
+ return Stream.of((Object[]) defaultValue).map(Object::toString).collect(Collectors.joining(", "));
+ }
+ else {
+ return defaultValue.toString();
+ }
+ }
+
+ @Override
+ public void close() {
+ this.out.close();
+ }
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-configuration-metadata-changelog-generator/src/main/java/org/springframework/boot/configurationmetadata/changelog/ConfigurationMetadataDiff.java b/spring-boot-project/spring-boot-tools/spring-boot-configuration-metadata-changelog-generator/src/main/java/org/springframework/boot/configurationmetadata/changelog/ConfigurationMetadataDiff.java
new file mode 100644
index 0000000000..260c7f95ea
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-configuration-metadata-changelog-generator/src/main/java/org/springframework/boot/configurationmetadata/changelog/ConfigurationMetadataDiff.java
@@ -0,0 +1,109 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.configurationmetadata.changelog;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+import org.springframework.boot.configurationmetadata.ConfigurationMetadataProperty;
+import org.springframework.boot.configurationmetadata.ConfigurationMetadataRepository;
+import org.springframework.boot.configurationmetadata.Deprecation.Level;
+import org.springframework.boot.configurationmetadata.changelog.ConfigurationMetadataDiff.Difference.Type;
+
+/**
+ * A diff of two repositories of configuration metadata.
+ *
+ * @param leftName the name of the left-hand side of the diff
+ * @param rightName the name of the right-hand side of the diff
+ * @param differences the differences
+ * @author Stephane Nicoll
+ * @author Andy Wilkinson
+ */
+record ConfigurationMetadataDiff(String leftName, String rightName, List differences) {
+
+ static ConfigurationMetadataDiff of(NamedConfigurationMetadataRepository left,
+ NamedConfigurationMetadataRepository right) {
+ return new ConfigurationMetadataDiff(left.getName(), right.getName(), differences(left, right));
+ }
+
+ private static List differences(ConfigurationMetadataRepository left,
+ ConfigurationMetadataRepository right) {
+ List differences = new ArrayList<>();
+ List matches = new ArrayList<>();
+ Map leftProperties = left.getAllProperties();
+ Map rightProperties = right.getAllProperties();
+ for (ConfigurationMetadataProperty leftProperty : leftProperties.values()) {
+ String id = leftProperty.getId();
+ matches.add(id);
+ ConfigurationMetadataProperty rightProperty = rightProperties.get(id);
+ if (rightProperty == null) {
+ if (!(leftProperty.isDeprecated() && leftProperty.getDeprecation().getLevel() == Level.ERROR)) {
+ differences.add(new Difference(Type.DELETED, leftProperty, null));
+ }
+ }
+ else if (rightProperty.isDeprecated() && !leftProperty.isDeprecated()) {
+ differences.add(new Difference(Type.DEPRECATED, leftProperty, rightProperty));
+ }
+ else if (leftProperty.isDeprecated() && leftProperty.getDeprecation().getLevel() == Level.WARNING
+ && rightProperty.isDeprecated() && rightProperty.getDeprecation().getLevel() == Level.ERROR) {
+ differences.add(new Difference(Type.DELETED, leftProperty, rightProperty));
+ }
+ }
+ for (ConfigurationMetadataProperty rightProperty : rightProperties.values()) {
+ if ((!matches.contains(rightProperty.getId())) && (!rightProperty.isDeprecated())) {
+ differences.add(new Difference(Type.ADDED, null, rightProperty));
+ }
+ }
+ return differences;
+ }
+
+ /**
+ * A difference in the metadata.
+ *
+ * @param type the type of the difference
+ * @param left the left-hand side of the difference
+ * @param right the right-hand side of the difference
+ */
+ static record Difference(Type type, ConfigurationMetadataProperty left, ConfigurationMetadataProperty right) {
+
+ /**
+ * The type of a difference in the metadata.
+ */
+ enum Type {
+
+ /**
+ * The entry has been added.
+ */
+ ADDED,
+
+ /**
+ * The entry has been made deprecated. It may or may not still exist in the
+ * previous version.
+ */
+ DEPRECATED,
+
+ /**
+ * The entry has been deleted.
+ */
+ DELETED
+
+ }
+
+ }
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-configuration-metadata-changelog-generator/src/main/java/org/springframework/boot/configurationmetadata/changelog/NamedConfigurationMetadataRepository.java b/spring-boot-project/spring-boot-tools/spring-boot-configuration-metadata-changelog-generator/src/main/java/org/springframework/boot/configurationmetadata/changelog/NamedConfigurationMetadataRepository.java
new file mode 100644
index 0000000000..51ec5535e3
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-configuration-metadata-changelog-generator/src/main/java/org/springframework/boot/configurationmetadata/changelog/NamedConfigurationMetadataRepository.java
@@ -0,0 +1,80 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.configurationmetadata.changelog;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.Map;
+import java.util.jar.JarEntry;
+import java.util.jar.JarFile;
+
+import org.springframework.boot.configurationmetadata.ConfigurationMetadataGroup;
+import org.springframework.boot.configurationmetadata.ConfigurationMetadataProperty;
+import org.springframework.boot.configurationmetadata.ConfigurationMetadataRepository;
+import org.springframework.boot.configurationmetadata.ConfigurationMetadataRepositoryJsonBuilder;
+
+/**
+ * A {@link ConfigurationMetadataRepository} with a name.
+ *
+ * @author Andy Wilkinson
+ */
+class NamedConfigurationMetadataRepository implements ConfigurationMetadataRepository {
+
+ private final String name;
+
+ private final ConfigurationMetadataRepository delegate;
+
+ NamedConfigurationMetadataRepository(String name, ConfigurationMetadataRepository delegate) {
+ this.name = name;
+ this.delegate = delegate;
+ }
+
+ /**
+ * The name of the metadata held in the repository.
+ * @return the name of the metadata
+ */
+ String getName() {
+ return this.name;
+ }
+
+ @Override
+ public Map getAllGroups() {
+ return this.delegate.getAllGroups();
+ }
+
+ @Override
+ public Map getAllProperties() {
+ return this.delegate.getAllProperties();
+ }
+
+ static NamedConfigurationMetadataRepository from(File metadataDir) {
+ ConfigurationMetadataRepositoryJsonBuilder builder = ConfigurationMetadataRepositoryJsonBuilder.create();
+ for (File jar : metadataDir.listFiles()) {
+ try (JarFile jarFile = new JarFile(jar)) {
+ JarEntry jsonMetadata = jarFile.getJarEntry("META-INF/spring-configuration-metadata.json");
+ if (jsonMetadata != null) {
+ builder.withJsonResource(jarFile.getInputStream(jsonMetadata));
+ }
+ }
+ catch (IOException ex) {
+ throw new RuntimeException(ex);
+ }
+ }
+ return new NamedConfigurationMetadataRepository(metadataDir.getName(), builder.build());
+ }
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-configuration-metadata-changelog-generator/src/main/java/org/springframework/boot/configurationmetadata/changelog/package-info.java b/spring-boot-project/spring-boot-tools/spring-boot-configuration-metadata-changelog-generator/src/main/java/org/springframework/boot/configurationmetadata/changelog/package-info.java
new file mode 100644
index 0000000000..96eca3173f
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-configuration-metadata-changelog-generator/src/main/java/org/springframework/boot/configurationmetadata/changelog/package-info.java
@@ -0,0 +1,20 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * Spring Boot configuration metadata changelog generator.
+ */
+package org.springframework.boot.configurationmetadata.changelog;
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-configuration-metadata-changelog-generator/src/test/java/org/springframework/boot/configurationmetadata/changelog/ConfigurationMetadataDiffTests.java b/spring-boot-project/spring-boot-tools/spring-boot-configuration-metadata-changelog-generator/src/test/java/org/springframework/boot/configurationmetadata/changelog/ConfigurationMetadataDiffTests.java
new file mode 100644
index 0000000000..787184a93f
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-configuration-metadata-changelog-generator/src/test/java/org/springframework/boot/configurationmetadata/changelog/ConfigurationMetadataDiffTests.java
@@ -0,0 +1,92 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.configurationmetadata.changelog;
+
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.List;
+import java.util.stream.Collectors;
+
+import org.junit.jupiter.api.Test;
+
+import org.springframework.boot.configurationmetadata.ConfigurationMetadataProperty;
+import org.springframework.boot.configurationmetadata.ConfigurationMetadataRepository;
+import org.springframework.boot.configurationmetadata.ConfigurationMetadataRepositoryJsonBuilder;
+import org.springframework.boot.configurationmetadata.changelog.ConfigurationMetadataDiff.Difference;
+import org.springframework.boot.configurationmetadata.changelog.ConfigurationMetadataDiff.Difference.Type;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Tests for {@link ConfigurationMetadataDiff}.
+ *
+ * @author Stephane Nicoll
+ * @author Andy Wilkinson
+ */
+class ConfigurationMetadataDiffTests {
+
+ @Test
+ void diffContainsDifferencesBetweenLeftAndRightInputs() {
+ NamedConfigurationMetadataRepository left = new NamedConfigurationMetadataRepository("1.0",
+ load("sample-1.0.json"));
+ NamedConfigurationMetadataRepository right = new NamedConfigurationMetadataRepository("2.0",
+ load("sample-2.0.json"));
+ ConfigurationMetadataDiff diff = ConfigurationMetadataDiff.of(left, right);
+ assertThat(diff).isNotNull();
+ assertThat(diff.leftName()).isEqualTo("1.0");
+ assertThat(diff.rightName()).isEqualTo("2.0");
+ assertThat(diff.differences()).hasSize(4);
+ List added = diff.differences()
+ .stream()
+ .filter((difference) -> difference.type() == Type.ADDED)
+ .collect(Collectors.toList());
+ assertThat(added).hasSize(1);
+ assertProperty(added.get(0).right(), "test.add", String.class, "new");
+ List deleted = diff.differences()
+ .stream()
+ .filter((difference) -> difference.type() == Type.DELETED)
+ .collect(Collectors.toList());
+ assertThat(deleted).hasSize(2)
+ .anySatisfy((entry) -> assertProperty(entry.left(), "test.delete", String.class, "delete"))
+ .anySatisfy((entry) -> assertProperty(entry.right(), "test.delete.deprecated", String.class, "delete"));
+ List deprecated = diff.differences()
+ .stream()
+ .filter((difference) -> difference.type() == Type.DEPRECATED)
+ .collect(Collectors.toList());
+ assertThat(deprecated).hasSize(1);
+ assertProperty(deprecated.get(0).left(), "test.deprecate", String.class, "wrong");
+ assertProperty(deprecated.get(0).right(), "test.deprecate", String.class, "wrong");
+ }
+
+ private void assertProperty(ConfigurationMetadataProperty property, String id, Class> type, Object defaultValue) {
+ assertThat(property).isNotNull();
+ assertThat(property.getId()).isEqualTo(id);
+ assertThat(property.getType()).isEqualTo(type.getName());
+ assertThat(property.getDefaultValue()).isEqualTo(defaultValue);
+ }
+
+ private ConfigurationMetadataRepository load(String filename) {
+ try (InputStream inputStream = new FileInputStream("src/test/resources/" + filename)) {
+ return ConfigurationMetadataRepositoryJsonBuilder.create(inputStream).build();
+ }
+ catch (IOException ex) {
+ throw new RuntimeException(ex);
+ }
+ }
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-configuration-metadata-changelog-generator/src/test/resources/sample-1.0.json b/spring-boot-project/spring-boot-tools/spring-boot-configuration-metadata-changelog-generator/src/test/resources/sample-1.0.json
new file mode 100644
index 0000000000..a0584bc569
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-configuration-metadata-changelog-generator/src/test/resources/sample-1.0.json
@@ -0,0 +1,31 @@
+{
+ "properties": [
+ {
+ "name": "test.equal",
+ "type": "java.lang.String",
+ "description": "Test equality.",
+ "defaultValue": "test"
+ },
+ {
+ "name": "test.deprecate",
+ "type": "java.lang.String",
+ "description": "Test deprecate.",
+ "defaultValue": "wrong"
+ },
+ {
+ "name": "test.delete",
+ "type": "java.lang.String",
+ "description": "Test delete.",
+ "defaultValue": "delete"
+ },
+ {
+ "name": "test.delete.deprecated",
+ "type": "java.lang.String",
+ "description": "Test delete deprecated.",
+ "defaultValue": "delete",
+ "deprecation": {
+ "level": "warning"
+ }
+ }
+ ]
+}
\ No newline at end of file
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-configuration-metadata-changelog-generator/src/test/resources/sample-2.0.json b/spring-boot-project/spring-boot-tools/spring-boot-configuration-metadata-changelog-generator/src/test/resources/sample-2.0.json
new file mode 100644
index 0000000000..2de71ca99e
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-configuration-metadata-changelog-generator/src/test/resources/sample-2.0.json
@@ -0,0 +1,34 @@
+{
+ "properties": [
+ {
+ "name": "test.add",
+ "type": "java.lang.String",
+ "description": "Test add.",
+ "defaultValue": "new"
+ },
+ {
+ "name": "test.equal",
+ "type": "java.lang.String",
+ "description": "Test equality.",
+ "defaultValue": "test"
+ },
+ {
+ "name": "test.deprecate",
+ "type": "java.lang.String",
+ "description": "Test deprecate.",
+ "defaultValue": "wrong",
+ "deprecation": {
+ "level": "error"
+ }
+ },
+ {
+ "name": "test.delete.deprecated",
+ "type": "java.lang.String",
+ "description": "Test delete deprecated.",
+ "defaultValue": "delete",
+ "deprecation": {
+ "level": "error"
+ }
+ }
+ ]
+}
\ No newline at end of file
diff --git a/src/checkstyle/checkstyle-suppressions.xml b/src/checkstyle/checkstyle-suppressions.xml
index 9062ad5e2b..3f7d8b5e06 100644
--- a/src/checkstyle/checkstyle-suppressions.xml
+++ b/src/checkstyle/checkstyle-suppressions.xml
@@ -70,6 +70,7 @@
+