Skip to content

Commit 7b2d908

Browse files
committed
Rewrite process for documenting managed dependencies
Previously, managed dependencies were documented using Gradle's dependency constraints. This has proven to be non-deterministic for reasons that are not fully understood. The working theory is that the constraints that are documented vary depending on the tasks that the build has run at the point at which the constraints are being examined and documented. This commit replaces approach with one that builds a model of a resolved bom by examining the configured bom extension and the XML of the Maven boms that it imports. This model is written to disk from where it can then be consumed as a dependency on other projects. The existing tasks for documenting the constrained versions and version properties have been rewritten to use the resolved bom model instead. Closes gh-44855
1 parent b67a655 commit 7b2d908

File tree

9 files changed

+631
-80
lines changed

9 files changed

+631
-80
lines changed

buildSrc/src/main/java/org/springframework/boot/build/bom/BomExtension.java

+7
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,8 @@
8282
*/
8383
public class BomExtension {
8484

85+
private final String id;
86+
8587
private final Project project;
8688

8789
private final UpgradeHandler upgradeHandler;
@@ -95,6 +97,11 @@ public class BomExtension {
9597
public BomExtension(Project project) {
9698
this.project = project;
9799
this.upgradeHandler = project.getObjects().newInstance(UpgradeHandler.class, project);
100+
this.id = "%s:%s:%s".formatted(project.getGroup(), project.getName(), project.getVersion());
101+
}
102+
103+
public String getId() {
104+
return this.id;
98105
}
99106

100107
public List<Library> getLibraries() {

buildSrc/src/main/java/org/springframework/boot/build/bom/BomPlugin.java

+6
Original file line numberDiff line numberDiff line change
@@ -62,11 +62,17 @@ public void apply(Project project) {
6262
javaPlatform.allowDependencies();
6363
createApiEnforcedConfiguration(project);
6464
BomExtension bom = project.getExtensions().create("bom", BomExtension.class, project);
65+
TaskProvider<CreateResolvedBom> createResolvedBom = project.getTasks()
66+
.register("createResolvedBom", CreateResolvedBom.class, bom);
6567
TaskProvider<CheckBom> checkBom = project.getTasks().register("bomrCheck", CheckBom.class, bom);
6668
project.getTasks().named("check").configure((check) -> check.dependsOn(checkBom));
6769
project.getTasks().register("bomrUpgrade", UpgradeBom.class, bom);
6870
project.getTasks().register("moveToSnapshots", MoveToSnapshots.class, bom);
6971
project.getTasks().register("checkLinks", CheckLinks.class, bom);
72+
Configuration resolvedBomConfiguration = project.getConfigurations().create("resolvedBom");
73+
project.getArtifacts()
74+
.add(resolvedBomConfiguration.getName(), createResolvedBom.map(CreateResolvedBom::getOutputFile),
75+
(artifact) -> artifact.builtBy(createResolvedBom));
7076
new PublishingCustomizer(project, bom).customize();
7177
}
7278

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,297 @@
1+
/*
2+
* Copyright 2012-2025 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.boot.build.bom;
18+
19+
import java.io.File;
20+
import java.util.ArrayList;
21+
import java.util.HashMap;
22+
import java.util.List;
23+
import java.util.Map;
24+
import java.util.Set;
25+
import java.util.function.Function;
26+
27+
import javax.xml.namespace.QName;
28+
import javax.xml.parsers.DocumentBuilder;
29+
import javax.xml.parsers.DocumentBuilderFactory;
30+
import javax.xml.parsers.ParserConfigurationException;
31+
import javax.xml.xpath.XPath;
32+
import javax.xml.xpath.XPathConstants;
33+
import javax.xml.xpath.XPathExpressionException;
34+
import javax.xml.xpath.XPathFactory;
35+
36+
import org.gradle.api.artifacts.ConfigurationContainer;
37+
import org.gradle.api.artifacts.ResolvedArtifact;
38+
import org.gradle.api.artifacts.dsl.DependencyHandler;
39+
import org.w3c.dom.Document;
40+
import org.w3c.dom.NodeList;
41+
42+
import org.springframework.boot.build.bom.Library.Group;
43+
import org.springframework.boot.build.bom.Library.Module;
44+
import org.springframework.boot.build.bom.ResolvedBom.Bom;
45+
import org.springframework.boot.build.bom.ResolvedBom.Id;
46+
import org.springframework.boot.build.bom.ResolvedBom.ResolvedLibrary;
47+
48+
/**
49+
* Creates a {@link ResolvedBom resolved bom}.
50+
*
51+
* @author Andy Wilkinson
52+
*/
53+
class BomResolver {
54+
55+
private final ConfigurationContainer configurations;
56+
57+
private final DependencyHandler dependencies;
58+
59+
private final DocumentBuilder documentBuilder;
60+
61+
BomResolver(ConfigurationContainer configurations, DependencyHandler dependencies) {
62+
this.configurations = configurations;
63+
this.dependencies = dependencies;
64+
try {
65+
this.documentBuilder = DocumentBuilderFactory.newInstance().newDocumentBuilder();
66+
}
67+
catch (ParserConfigurationException ex) {
68+
throw new RuntimeException(ex);
69+
}
70+
}
71+
72+
ResolvedBom resolve(BomExtension bomExtension) {
73+
List<ResolvedLibrary> libraries = new ArrayList<>();
74+
for (Library library : bomExtension.getLibraries()) {
75+
List<Id> managedDependencies = new ArrayList<>();
76+
List<Bom> imports = new ArrayList<>();
77+
for (Group group : library.getGroups()) {
78+
for (Module module : group.getModules()) {
79+
Id id = new Id(group.getId(), module.getName(), library.getVersion().getVersion().toString());
80+
managedDependencies.add(id);
81+
}
82+
for (String imported : group.getBoms()) {
83+
Bom bom = bomFrom(resolveBom(
84+
"%s:%s:%s".formatted(group.getId(), imported, library.getVersion().getVersion())));
85+
imports.add(bom);
86+
}
87+
}
88+
ResolvedLibrary resolvedLibrary = new ResolvedLibrary(library.getName(),
89+
library.getVersion().getVersion().toString(), library.getVersionProperty(), managedDependencies,
90+
imports);
91+
libraries.add(resolvedLibrary);
92+
}
93+
String[] idComponents = bomExtension.getId().split(":");
94+
return new ResolvedBom(new Id(idComponents[0], idComponents[1], idComponents[2]), libraries);
95+
}
96+
97+
Bom resolveMavenBom(String coordinates) {
98+
return bomFrom(resolveBom(coordinates));
99+
}
100+
101+
private File resolveBom(String coordinates) {
102+
Set<ResolvedArtifact> artifacts = this.configurations
103+
.detachedConfiguration(this.dependencies.create(coordinates + "@pom"))
104+
.getResolvedConfiguration()
105+
.getResolvedArtifacts();
106+
if (artifacts.size() != 1) {
107+
throw new IllegalStateException("Expected a single artifact but '%s' resolved to %d artifacts"
108+
.formatted(coordinates, artifacts.size()));
109+
}
110+
return artifacts.iterator().next().getFile();
111+
}
112+
113+
private Bom bomFrom(File bomFile) {
114+
try {
115+
Node bom = nodeFrom(bomFile);
116+
File parentBomFile = parentBomFile(bom);
117+
Bom parent = null;
118+
if (parentBomFile != null) {
119+
parent = bomFrom(parentBomFile);
120+
}
121+
Properties properties = Properties.from(bom, this::nodeFrom);
122+
List<Node> dependencyNodes = bom.nodesAt("/project/dependencyManagement/dependencies/dependency");
123+
List<Id> managedDependencies = new ArrayList<>();
124+
List<Bom> imports = new ArrayList<>();
125+
for (Node dependency : dependencyNodes) {
126+
String groupId = properties.replace(dependency.textAt("groupId"));
127+
String artifactId = properties.replace(dependency.textAt("artifactId"));
128+
String version = properties.replace(dependency.textAt("version"));
129+
String classifier = properties.replace(dependency.textAt("classifier"));
130+
String scope = properties.replace(dependency.textAt("scope"));
131+
Bom importedBom = null;
132+
if ("import".equals(scope)) {
133+
String type = properties.replace(dependency.textAt("type"));
134+
if ("pom".equals(type)) {
135+
importedBom = bomFrom(resolveBom(groupId + ":" + artifactId + ":" + version));
136+
}
137+
}
138+
if (importedBom != null) {
139+
imports.add(importedBom);
140+
}
141+
else {
142+
managedDependencies.add(new Id(groupId, artifactId, version, classifier));
143+
}
144+
}
145+
String groupId = bom.textAt("/project/groupId");
146+
if ((groupId == null || groupId.isEmpty()) && parent != null) {
147+
groupId = parent.id().groupId();
148+
}
149+
String artifactId = bom.textAt("/project/artifactId");
150+
String version = bom.textAt("/project/version");
151+
if ((version == null || version.isEmpty()) && parent != null) {
152+
version = parent.id().version();
153+
}
154+
return new Bom(new Id(groupId, artifactId, version), parent, managedDependencies, imports);
155+
}
156+
catch (Exception ex) {
157+
throw new RuntimeException(ex);
158+
}
159+
}
160+
161+
private Node nodeFrom(String coordinates) {
162+
return nodeFrom(resolveBom(coordinates));
163+
}
164+
165+
private Node nodeFrom(File bomFile) {
166+
try {
167+
Document document = this.documentBuilder.parse(bomFile);
168+
return new Node(document);
169+
}
170+
catch (Exception ex) {
171+
throw new RuntimeException(ex);
172+
}
173+
}
174+
175+
private File parentBomFile(Node bom) {
176+
Node parent = bom.nodeAt("/project/parent");
177+
if (parent != null) {
178+
String parentGroupId = parent.textAt("groupId");
179+
String parentArtifactId = parent.textAt("artifactId");
180+
String parentVersion = parent.textAt("version");
181+
return resolveBom(parentGroupId + ":" + parentArtifactId + ":" + parentVersion);
182+
}
183+
return null;
184+
}
185+
186+
private static final class Node {
187+
188+
protected final XPath xpath;
189+
190+
private final org.w3c.dom.Node delegate;
191+
192+
private Node(org.w3c.dom.Node delegate) {
193+
this(delegate, XPathFactory.newInstance().newXPath());
194+
}
195+
196+
private Node(org.w3c.dom.Node delegate, XPath xpath) {
197+
this.delegate = delegate;
198+
this.xpath = xpath;
199+
}
200+
201+
private String textAt(String expression) {
202+
String text = (String) evaluate(expression + "/text()", XPathConstants.STRING);
203+
return (text != null && !text.isBlank()) ? text : null;
204+
}
205+
206+
private Node nodeAt(String expression) {
207+
org.w3c.dom.Node result = (org.w3c.dom.Node) evaluate(expression, XPathConstants.NODE);
208+
return (result != null) ? new Node(result, this.xpath) : null;
209+
}
210+
211+
private List<Node> nodesAt(String expression) {
212+
NodeList nodes = (NodeList) evaluate(expression, XPathConstants.NODESET);
213+
List<Node> things = new ArrayList<>(nodes.getLength());
214+
for (int i = 0; i < nodes.getLength(); i++) {
215+
things.add(new Node(nodes.item(i), this.xpath));
216+
}
217+
return things;
218+
}
219+
220+
private Object evaluate(String expression, QName type) {
221+
try {
222+
return this.xpath.evaluate(expression, this.delegate, type);
223+
}
224+
catch (XPathExpressionException ex) {
225+
throw new RuntimeException(ex);
226+
}
227+
}
228+
229+
private String name() {
230+
return this.delegate.getNodeName();
231+
}
232+
233+
private String textContent() {
234+
return this.delegate.getTextContent();
235+
}
236+
237+
}
238+
239+
private static final class Properties {
240+
241+
private final Map<String, String> properties;
242+
243+
private Properties(Map<String, String> properties) {
244+
this.properties = properties;
245+
}
246+
247+
private static Properties from(Node bom, Function<String, Node> resolver) {
248+
try {
249+
Map<String, String> properties = new HashMap<>();
250+
Node current = bom;
251+
while (current != null) {
252+
String groupId = current.textAt("/project/groupId");
253+
if (groupId != null && !groupId.isEmpty()) {
254+
properties.putIfAbsent("${project.groupId}", groupId);
255+
}
256+
String version = current.textAt("/project/version");
257+
if (version != null && !version.isEmpty()) {
258+
properties.putIfAbsent("${project.version}", version);
259+
}
260+
List<Node> propertyNodes = current.nodesAt("/project/properties/*");
261+
for (Node property : propertyNodes) {
262+
properties.putIfAbsent("${%s}".formatted(property.name()), property.textContent());
263+
}
264+
current = parent(current, resolver);
265+
}
266+
return new Properties(properties);
267+
}
268+
catch (Exception ex) {
269+
throw new RuntimeException(ex);
270+
}
271+
}
272+
273+
private static Node parent(Node current, Function<String, Node> resolver) {
274+
Node parent = current.nodeAt("/project/parent");
275+
if (parent != null) {
276+
String parentGroupId = parent.textAt("groupId");
277+
String parentArtifactId = parent.textAt("artifactId");
278+
String parentVersion = parent.textAt("version");
279+
return resolver.apply(parentGroupId + ":" + parentArtifactId + ":" + parentVersion);
280+
}
281+
return null;
282+
}
283+
284+
private String replace(String input) {
285+
if (input != null && input.startsWith("${") && input.endsWith("}")) {
286+
String value = this.properties.get(input);
287+
if (value != null) {
288+
return replace(value);
289+
}
290+
throw new IllegalStateException("No replacement for " + input);
291+
}
292+
return input;
293+
}
294+
295+
}
296+
297+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
/*
2+
* Copyright 2025 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.boot.build.bom;
18+
19+
import java.io.FileWriter;
20+
import java.io.IOException;
21+
22+
import javax.inject.Inject;
23+
24+
import org.gradle.api.DefaultTask;
25+
import org.gradle.api.Task;
26+
import org.gradle.api.file.RegularFileProperty;
27+
import org.gradle.api.tasks.OutputFile;
28+
import org.gradle.api.tasks.TaskAction;
29+
30+
/**
31+
* {@link Task} to create a {@link ResolvedBom resolved bom}.
32+
*
33+
* @author Andy Wilkinson
34+
*/
35+
public abstract class CreateResolvedBom extends DefaultTask {
36+
37+
private final BomExtension bomExtension;
38+
39+
private final BomResolver bomResolver;
40+
41+
@Inject
42+
public CreateResolvedBom(BomExtension bomExtension) {
43+
this.bomExtension = bomExtension;
44+
this.bomResolver = new BomResolver(getProject().getConfigurations(), getProject().getDependencies());
45+
getOutputFile().convention(getProject().getLayout().getBuildDirectory().file(getName() + "/resolved-bom.json"));
46+
}
47+
48+
@OutputFile
49+
public abstract RegularFileProperty getOutputFile();
50+
51+
@TaskAction
52+
void describeDependencyManagement() throws IOException {
53+
ResolvedBom dependencyManagement = this.bomResolver.resolve(this.bomExtension);
54+
try (FileWriter writer = new FileWriter(getOutputFile().get().getAsFile())) {
55+
dependencyManagement.writeTo(writer);
56+
}
57+
}
58+
59+
}

0 commit comments

Comments
 (0)