Skip to content

Commit ccbacea

Browse files
gnodetclaude
andcommitted
Fail-fast consumer POM validation for non-4.0.0 model versions (PR #11780)
Cherry-pick from gh-11772-consumer-pom-validation branch. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 7d9a345 commit ccbacea

9 files changed

Lines changed: 282 additions & 9 deletions

File tree

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,3 +22,5 @@ repo/
2222
# Mac
2323
.DS_Store
2424

25+
.claude/
26+
.omc/

api/maven-api-core/src/main/java/org/apache/maven/api/Constants.java

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -489,6 +489,21 @@ public final class Constants {
489489
@Config(type = "java.lang.Boolean", defaultValue = "false")
490490
public static final String MAVEN_CONSUMER_POM_FLATTEN = "maven.consumer.pom.flatten";
491491

492+
/**
493+
* User property for controlling removal of unused managed dependencies during consumer POM flattening.
494+
* When set to {@code true} (default), managed dependencies that do not appear in the resolved
495+
* dependency tree are removed from the consumer POM to keep it lean. This is important when using
496+
* BOMs like Spring Boot or Quarkus that contain hundreds of managed dependency entries.
497+
* When set to {@code false}, all managed dependencies are preserved in the consumer POM,
498+
* which may be needed in rare cases where downstream consumers override transitive dependency
499+
* versions and rely on the original managed dependencies for alignment.
500+
*
501+
* @since 4.1.0
502+
*/
503+
@Config(type = "java.lang.Boolean", defaultValue = "true")
504+
public static final String MAVEN_CONSUMER_POM_REMOVE_UNUSED_MANAGED_DEPENDENCIES =
505+
"maven.consumer.pom.removeUnusedManagedDependencies";
506+
492507
/**
493508
* User property for controlling "maven personality". If activated Maven will behave
494509
* as previous major version, Maven 3.

api/maven-api-core/src/main/java/org/apache/maven/api/feature/Features.java

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,20 @@ public static boolean consumerPomFlatten(@Nullable Map<String, ?> userProperties
5555
return doGet(userProperties, Constants.MAVEN_CONSUMER_POM_FLATTEN, false);
5656
}
5757

58+
/**
59+
* Check if unused managed dependency removal is enabled during consumer POM flattening.
60+
*/
61+
public static boolean consumerPomRemoveUnusedManagedDependencies(@Nullable Map<String, ?> userProperties) {
62+
return doGet(userProperties, Constants.MAVEN_CONSUMER_POM_REMOVE_UNUSED_MANAGED_DEPENDENCIES, true);
63+
}
64+
65+
/**
66+
* Check if build POM deployment is enabled.
67+
*/
68+
public static boolean deployBuildPom(@Nullable Map<String, ?> userProperties) {
69+
return doGet(userProperties, Constants.MAVEN_DEPLOY_BUILD_POM, true);
70+
}
71+
5872
private static boolean doGet(Properties userProperties, String key, boolean def) {
5973
return doGet(userProperties != null ? userProperties.get(key) : null, def);
6074
}

impl/maven-core/src/main/java/org/apache/maven/internal/transformation/impl/DefaultConsumerPomBuilder.java

Lines changed: 28 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,30 @@ public Model build(RepositorySystemSession session, MavenProject project, ModelS
9090
if (isBom) {
9191
return buildBomWithoutFlatten(session, project, src);
9292
} else {
93-
return buildPom(session, project, src);
93+
Model result = buildPom(session, project, src);
94+
// Validate POM-packaged projects (parent POMs): if the consumer POM cannot be
95+
// downgraded to 4.0.0, Maven 3 / Gradle cannot resolve the parent.
96+
// Non-POM projects are consumed as dependencies where unknown elements are
97+
// ignored, so a higher model version is acceptable (only a warning is logged
98+
// by transformNonPom/transformPom).
99+
if (POM_PACKAGING.equals(packaging)
100+
&& !model.isPreserveModelVersion()
101+
&& !ModelBuilder.MODEL_VERSION_4_0_0.equals(result.getModelVersion())) {
102+
throw new MavenException("The consumer POM for " + project.getId()
103+
+ " cannot be downgraded to model version 4.0.0 because it contains"
104+
+ " features that require a newer model version."
105+
+ " Since consumer POM flattening is disabled, the parent reference is"
106+
+ " preserved, which requires consumers to resolve the parent POM." + System.lineSeparator()
107+
+ "You have the following options to resolve this:" + System.lineSeparator()
108+
+ " 1. Enable flattening by setting the property 'maven.consumer.pom.flatten=true'"
109+
+ " to inline parent content and produce a self-contained 4.0.0 consumer POM"
110+
+ System.lineSeparator()
111+
+ " 2. Preserve the model version by setting 'preserve.model.version=true'"
112+
+ " on the <project> element (Maven 4 consumers only)"
113+
+ System.lineSeparator()
114+
+ " 3. Remove the features that require a newer model version");
115+
}
116+
return result;
94117
}
95118
}
96119
// Default behavior: flatten the consumer POM
@@ -138,6 +161,8 @@ private Model buildEffectiveModel(RepositorySystemSession session, ModelSource s
138161
InternalSession iSession = InternalSession.from(session);
139162
ModelBuilderResult result = buildModel(session, src);
140163
Model model = result.getEffectiveModel();
164+
boolean removeUnusedManagedDeps =
165+
Features.consumerPomRemoveUnusedManagedDependencies(session.getConfigProperties());
141166

142167
if (model.getDependencyManagement() != null
143168
&& !model.getDependencyManagement().getDependencies().isEmpty()) {
@@ -156,20 +181,14 @@ private Model buildEffectiveModel(RepositorySystemSession session, ModelSource s
156181
this::merge,
157182
LinkedHashMap::new));
158183
Map<String, Dependency> managedDependencies = model.getDependencyManagement().getDependencies().stream()
159-
.filter(dependency ->
160-
nodes.containsKey(getDependencyKey(dependency)) && !"import".equals(dependency.getScope()))
184+
.filter(dependency -> !"import".equals(dependency.getScope())
185+
&& (!removeUnusedManagedDeps || nodes.containsKey(getDependencyKey(dependency))))
161186
.collect(Collectors.toMap(
162187
DefaultConsumerPomBuilder::getDependencyKey,
163188
Function.identity(),
164189
this::merge,
165190
LinkedHashMap::new));
166191

167-
// for each managed dep in the model:
168-
// * if there is no corresponding node in the tree, discard the managed dep
169-
// * if there's a direct dependency, apply the managed dependency to it and discard the managed dep
170-
// * else keep the managed dep
171-
managedDependencies.keySet().retainAll(nodes.keySet());
172-
173192
directDependencies.replaceAll((key, dependency) -> {
174193
var managedDependency = managedDependencies.get(key);
175194
if (managedDependency != null) {

impl/maven-core/src/test/java/org/apache/maven/internal/transformation/impl/ConsumerPomBuilderTest.java

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
import org.apache.maven.api.model.Scm;
3535
import org.apache.maven.api.services.DependencyResolver;
3636
import org.apache.maven.api.services.DependencyResolverResult;
37+
import org.apache.maven.api.services.MavenException;
3738
import org.apache.maven.api.services.ModelBuilder;
3839
import org.apache.maven.api.services.ModelBuilderRequest;
3940
import org.apache.maven.api.services.Sources;
@@ -54,6 +55,7 @@
5455
import static org.junit.jupiter.api.Assertions.assertFalse;
5556
import static org.junit.jupiter.api.Assertions.assertNotNull;
5657
import static org.junit.jupiter.api.Assertions.assertNull;
58+
import static org.junit.jupiter.api.Assertions.assertThrows;
5759
import static org.junit.jupiter.api.Assertions.assertTrue;
5860

5961
public class ConsumerPomBuilderTest extends AbstractRepositoryTestCase {
@@ -181,6 +183,19 @@ void testMultiModuleConsumerPreserveModelVersion() throws Exception {
181183
assertFalse(transformed.getDependencyManagement().getDependencies().isEmpty());
182184
}
183185

186+
@Test
187+
void testParentWithConditionsFailsConsumerPom() throws Exception {
188+
setRootDirectory("parent-with-conditions");
189+
Path file = Paths.get("src/test/resources/consumer/parent-with-conditions/pom.xml");
190+
191+
MavenProject project = getEffectiveModel(file);
192+
// A parent POM with profile conditions cannot be downgraded to 4.0.0,
193+
// so building the consumer POM should fail with actionable guidance.
194+
MavenException ex =
195+
assertThrows(MavenException.class, () -> builder.build(session, project, Sources.buildSource(file)));
196+
assertTrue(ex.getMessage().contains("cannot be downgraded to model version 4.0.0"));
197+
}
198+
184199
@Test
185200
void testScmInheritance() throws Exception {
186201
Model model = Model.newBuilder()
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
<project root="true" xmlns="http://maven.apache.org/POM/4.1.0">
2+
<groupId>org.my.group</groupId>
3+
<artifactId>parent</artifactId>
4+
<version>1.0-SNAPSHOT</version>
5+
<packaging>pom</packaging>
6+
7+
<dependencyManagement>
8+
<dependencies>
9+
<dependency>
10+
<groupId>org.slf4j</groupId>
11+
<artifactId>slf4j-api</artifactId>
12+
<version>2.0.9</version>
13+
</dependency>
14+
</dependencies>
15+
</dependencyManagement>
16+
17+
<profiles>
18+
<profile>
19+
<id>test-profile</id>
20+
<activation>
21+
<condition>${project.artifactId} == 'parent'</condition>
22+
</activation>
23+
<dependencies>
24+
<dependency>
25+
<groupId>org.slf4j</groupId>
26+
<artifactId>slf4j-api</artifactId>
27+
</dependency>
28+
</dependencies>
29+
</profile>
30+
</profiles>
31+
32+
</project>
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
package org.apache.maven.it;
20+
21+
import java.io.File;
22+
import java.io.Reader;
23+
import java.nio.file.Files;
24+
import java.nio.file.Path;
25+
26+
import org.apache.maven.api.model.Model;
27+
import org.apache.maven.model.v4.MavenStaxReader;
28+
import org.junit.jupiter.api.Test;
29+
30+
import static org.junit.jupiter.api.Assertions.assertEquals;
31+
import static org.junit.jupiter.api.Assertions.assertNotNull;
32+
import static org.junit.jupiter.api.Assertions.assertTrue;
33+
34+
/**
35+
* Integration test for <a href="https://github.com/apache/maven/issues/11772">GH-11772</a>.
36+
* <p>
37+
* Verifies that when a parent+child project uses model version 4.1.0 namespace
38+
* (with subprojects, root), the installed consumer POMs are model version 4.0.0,
39+
* while the build POMs retain the original 4.1.0 content.
40+
* <p>
41+
* This ensures backward compatibility with Maven 3 and Gradle for consumer POMs
42+
* while Maven 4 builds can resolve the full-fidelity build POM.
43+
*/
44+
class MavenITgh11772ConsumerPom410Test extends AbstractMavenIntegrationTestCase {
45+
46+
private static final String GROUP_ID = "org.apache.maven.its.gh11772";
47+
48+
@Test
49+
void testConsumerPomsAre400BuildPomsAre410() throws Exception {
50+
File basedir = extractResources("/gh-11772-consumer-pom-410");
51+
52+
Verifier verifier = newVerifier(basedir.getAbsolutePath());
53+
verifier.deleteArtifacts(GROUP_ID);
54+
verifier.addCliArguments("install");
55+
verifier.execute();
56+
verifier.verifyErrorFreeLog();
57+
58+
// Verify parent consumer POM (main artifact) is 4.0.0
59+
Path parentConsumerPom =
60+
Path.of(verifier.getArtifactPath(GROUP_ID, "parent", "1.0.0-SNAPSHOT", "pom"));
61+
assertTrue(Files.exists(parentConsumerPom), "Parent consumer POM should exist");
62+
Model parentConsumer = readModel(parentConsumerPom);
63+
assertEquals("4.0.0", parentConsumer.getModelVersion(), "Parent consumer POM should be 4.0.0");
64+
65+
// Verify parent build POM retains 4.1.0 features
66+
Path parentBuildPom =
67+
Path.of(verifier.getArtifactPath(GROUP_ID, "parent", "1.0.0-SNAPSHOT", "pom", "build"));
68+
assertTrue(Files.exists(parentBuildPom), "Parent build POM should exist");
69+
Model parentBuild = readModel(parentBuildPom);
70+
// Build POM should retain subprojects (4.1.0 feature)
71+
assertNotNull(parentBuild.getSubprojects(), "Build POM should retain subprojects");
72+
assertTrue(!parentBuild.getSubprojects().isEmpty(), "Build POM should retain subprojects");
73+
74+
// Verify child consumer POM is 4.0.0
75+
Path childConsumerPom =
76+
Path.of(verifier.getArtifactPath(GROUP_ID, "child", "1.0.0-SNAPSHOT", "pom"));
77+
assertTrue(Files.exists(childConsumerPom), "Child consumer POM should exist");
78+
Model childConsumer = readModel(childConsumerPom);
79+
assertEquals("4.0.0", childConsumer.getModelVersion(), "Child consumer POM should be 4.0.0");
80+
81+
// Child consumer POM should have a parent reference (not flattened by default)
82+
assertNotNull(childConsumer.getParent(), "Child consumer POM should have a parent reference");
83+
assertEquals(GROUP_ID, childConsumer.getParent().getGroupId());
84+
assertEquals("parent", childConsumer.getParent().getArtifactId());
85+
86+
// Verify child build POM exists
87+
Path childBuildPom =
88+
Path.of(verifier.getArtifactPath(GROUP_ID, "child", "1.0.0-SNAPSHOT", "pom", "build"));
89+
assertTrue(Files.exists(childBuildPom), "Child build POM should exist");
90+
}
91+
92+
private static Model readModel(Path pomFile) throws Exception {
93+
try (Reader r = Files.newBufferedReader(pomFile)) {
94+
return new MavenStaxReader().read(r);
95+
}
96+
}
97+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<!--
3+
Licensed to the Apache Software Foundation (ASF) under one
4+
or more contributor license agreements. See the NOTICE file
5+
distributed with this work for additional information
6+
regarding copyright ownership. The ASF licenses this file
7+
to you under the Apache License, Version 2.0 (the
8+
"License"); you may not use this file except in compliance
9+
with the License. You may obtain a copy of the License at
10+
11+
http://www.apache.org/licenses/LICENSE-2.0
12+
13+
Unless required by applicable law or agreed to in writing,
14+
software distributed under the License is distributed on an
15+
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
16+
KIND, either express or implied. See the License for the
17+
specific language governing permissions and limitations
18+
under the License.
19+
-->
20+
<project xmlns="http://maven.apache.org/POM/4.1.0">
21+
22+
<parent>
23+
<groupId>org.apache.maven.its.gh11772</groupId>
24+
<artifactId>parent</artifactId>
25+
<version>1.0.0-SNAPSHOT</version>
26+
</parent>
27+
28+
<artifactId>child</artifactId>
29+
<packaging>pom</packaging>
30+
31+
<dependencies>
32+
<dependency>
33+
<groupId>org.slf4j</groupId>
34+
<artifactId>slf4j-api</artifactId>
35+
</dependency>
36+
</dependencies>
37+
38+
</project>
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<!--
3+
Licensed to the Apache Software Foundation (ASF) under one
4+
or more contributor license agreements. See the NOTICE file
5+
distributed with this work for additional information
6+
regarding copyright ownership. The ASF licenses this file
7+
to you under the Apache License, Version 2.0 (the
8+
"License"); you may not use this file except in compliance
9+
with the License. You may obtain a copy of the License at
10+
11+
http://www.apache.org/licenses/LICENSE-2.0
12+
13+
Unless required by applicable law or agreed to in writing,
14+
software distributed under the License is distributed on an
15+
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
16+
KIND, either express or implied. See the License for the
17+
specific language governing permissions and limitations
18+
under the License.
19+
-->
20+
<project xmlns="http://maven.apache.org/POM/4.1.0" root="true">
21+
22+
<groupId>org.apache.maven.its.gh11772</groupId>
23+
<artifactId>parent</artifactId>
24+
<version>1.0.0-SNAPSHOT</version>
25+
<packaging>pom</packaging>
26+
27+
<subprojects>
28+
<subproject>child</subproject>
29+
</subprojects>
30+
31+
<dependencyManagement>
32+
<dependencies>
33+
<dependency>
34+
<groupId>org.slf4j</groupId>
35+
<artifactId>slf4j-api</artifactId>
36+
<version>2.0.9</version>
37+
</dependency>
38+
</dependencies>
39+
</dependencyManagement>
40+
41+
</project>

0 commit comments

Comments
 (0)