diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/Constants.java b/api/maven-api-core/src/main/java/org/apache/maven/api/Constants.java index 096378cfbbe8..ee054f344abf 100644 --- a/api/maven-api-core/src/main/java/org/apache/maven/api/Constants.java +++ b/api/maven-api-core/src/main/java/org/apache/maven/api/Constants.java @@ -489,6 +489,21 @@ public final class Constants { @Config(type = "java.lang.Boolean", defaultValue = "false") public static final String MAVEN_CONSUMER_POM_FLATTEN = "maven.consumer.pom.flatten"; + /** + * User property for controlling removal of unused managed dependencies during consumer POM flattening. + * When set to {@code true} (default), managed dependencies that do not appear in the resolved + * dependency tree are removed from the consumer POM to keep it lean. This is important when using + * BOMs like Spring Boot or Quarkus that contain hundreds of managed dependency entries. + * When set to {@code false}, all managed dependencies are preserved in the consumer POM, + * which may be needed in rare cases where downstream consumers override transitive dependency + * versions and rely on the original managed dependencies for alignment. + * + * @since 4.1.0 + */ + @Config(type = "java.lang.Boolean", defaultValue = "true") + public static final String MAVEN_CONSUMER_POM_REMOVE_UNUSED_MANAGED_DEPENDENCIES = + "maven.consumer.pom.removeUnusedManagedDependencies"; + /** * User property for controlling "maven personality". If activated Maven will behave * as previous major version, Maven 3. diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/feature/Features.java b/api/maven-api-core/src/main/java/org/apache/maven/api/feature/Features.java index 8ab5a2006781..5ad36599b0e6 100644 --- a/api/maven-api-core/src/main/java/org/apache/maven/api/feature/Features.java +++ b/api/maven-api-core/src/main/java/org/apache/maven/api/feature/Features.java @@ -19,7 +19,6 @@ package org.apache.maven.api.feature; import java.util.Map; -import java.util.Properties; import org.apache.maven.api.Constants; import org.apache.maven.api.annotations.Nullable; @@ -55,8 +54,11 @@ public static boolean consumerPomFlatten(@Nullable Map userProperties return doGet(userProperties, Constants.MAVEN_CONSUMER_POM_FLATTEN, false); } - private static boolean doGet(Properties userProperties, String key, boolean def) { - return doGet(userProperties != null ? userProperties.get(key) : null, def); + /** + * Check if unused managed dependency removal is enabled during consumer POM flattening. + */ + public static boolean consumerPomRemoveUnusedManagedDependencies(@Nullable Map userProperties) { + return doGet(userProperties, Constants.MAVEN_CONSUMER_POM_REMOVE_UNUSED_MANAGED_DEPENDENCIES, true); } private static boolean doGet(Map userProperties, String key, boolean def) { diff --git a/impl/maven-core/src/main/java/org/apache/maven/internal/transformation/impl/DefaultConsumerPomBuilder.java b/impl/maven-core/src/main/java/org/apache/maven/internal/transformation/impl/DefaultConsumerPomBuilder.java index d597b15ad39c..fb5628933e49 100644 --- a/impl/maven-core/src/main/java/org/apache/maven/internal/transformation/impl/DefaultConsumerPomBuilder.java +++ b/impl/maven-core/src/main/java/org/apache/maven/internal/transformation/impl/DefaultConsumerPomBuilder.java @@ -41,6 +41,7 @@ import org.apache.maven.api.model.Profile; import org.apache.maven.api.model.Repository; import org.apache.maven.api.model.Scm; +import org.apache.maven.api.services.MavenException; import org.apache.maven.api.services.ModelBuilder; import org.apache.maven.api.services.ModelBuilderException; import org.apache.maven.api.services.ModelBuilderRequest; @@ -90,7 +91,28 @@ public Model build(RepositorySystemSession session, MavenProject project, ModelS if (isBom) { return buildBomWithoutFlatten(session, project, src); } else { - return buildPom(session, project, src); + Model result = buildPom(session, project, src); + // Validate POM-packaged projects (parent POMs): if the consumer POM cannot be + // downgraded to 4.0.0, Maven 3 / Gradle cannot resolve the parent. + // Non-POM projects are consumed as dependencies where unknown elements are + // ignored, so a higher model version is acceptable (only a warning is logged + // by transformNonPom/transformPom). + if (POM_PACKAGING.equals(packaging) + && !model.isPreserveModelVersion() + && !ModelBuilder.MODEL_VERSION_4_0_0.equals(result.getModelVersion())) { + throw new MavenException(""" + The consumer POM for %s cannot be downgraded to model version 4.0.0 because it contains\ + features that require a newer model version.\ + Since consumer POM flattening is disabled, the parent reference is\ + preserved, which requires consumers to resolve the parent POM. + You have the following options to resolve this: + 1. Enable flattening by setting the property 'maven.consumer.pom.flatten=true'\ + to inline parent content and produce a self-contained 4.0.0 consumer POM + 2. Preserve the model version by setting 'preserve.model.version=true'\ + on the element (Maven 4 consumers only) + 3. Remove the features that require a newer model version""".formatted(project.getId())); + } + return result; } } // Default behavior: flatten the consumer POM @@ -138,6 +160,8 @@ private Model buildEffectiveModel(RepositorySystemSession session, ModelSource s InternalSession iSession = InternalSession.from(session); ModelBuilderResult result = buildModel(session, src); Model model = result.getEffectiveModel(); + boolean removeUnusedManagedDeps = + Features.consumerPomRemoveUnusedManagedDependencies(session.getConfigProperties()); if (model.getDependencyManagement() != null && !model.getDependencyManagement().getDependencies().isEmpty()) { @@ -156,20 +180,14 @@ private Model buildEffectiveModel(RepositorySystemSession session, ModelSource s this::merge, LinkedHashMap::new)); Map managedDependencies = model.getDependencyManagement().getDependencies().stream() - .filter(dependency -> - nodes.containsKey(getDependencyKey(dependency)) && !"import".equals(dependency.getScope())) + .filter(dependency -> !"import".equals(dependency.getScope()) + && (!removeUnusedManagedDeps || nodes.containsKey(getDependencyKey(dependency)))) .collect(Collectors.toMap( DefaultConsumerPomBuilder::getDependencyKey, Function.identity(), this::merge, LinkedHashMap::new)); - // for each managed dep in the model: - // * if there is no corresponding node in the tree, discard the managed dep - // * if there's a direct dependency, apply the managed dependency to it and discard the managed dep - // * else keep the managed dep - managedDependencies.keySet().retainAll(nodes.keySet()); - directDependencies.replaceAll((key, dependency) -> { var managedDependency = managedDependencies.get(key); if (managedDependency != null) { diff --git a/impl/maven-core/src/test/java/org/apache/maven/internal/transformation/impl/ConsumerPomBuilderTest.java b/impl/maven-core/src/test/java/org/apache/maven/internal/transformation/impl/ConsumerPomBuilderTest.java index d9744cad5fcb..c0491441d475 100644 --- a/impl/maven-core/src/test/java/org/apache/maven/internal/transformation/impl/ConsumerPomBuilderTest.java +++ b/impl/maven-core/src/test/java/org/apache/maven/internal/transformation/impl/ConsumerPomBuilderTest.java @@ -34,6 +34,7 @@ import org.apache.maven.api.model.Scm; import org.apache.maven.api.services.DependencyResolver; import org.apache.maven.api.services.DependencyResolverResult; +import org.apache.maven.api.services.MavenException; import org.apache.maven.api.services.ModelBuilder; import org.apache.maven.api.services.ModelBuilderRequest; import org.apache.maven.api.services.Sources; @@ -54,6 +55,7 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; public class ConsumerPomBuilderTest extends AbstractRepositoryTestCase { @@ -181,6 +183,19 @@ void testMultiModuleConsumerPreserveModelVersion() throws Exception { assertFalse(transformed.getDependencyManagement().getDependencies().isEmpty()); } + @Test + void testParentWithConditionsFailsConsumerPom() throws Exception { + setRootDirectory("parent-with-conditions"); + Path file = Paths.get("src/test/resources/consumer/parent-with-conditions/pom.xml"); + + MavenProject project = getEffectiveModel(file); + // A parent POM with profile conditions cannot be downgraded to 4.0.0, + // so building the consumer POM should fail with actionable guidance. + MavenException ex = + assertThrows(MavenException.class, () -> builder.build(session, project, Sources.buildSource(file))); + assertTrue(ex.getMessage().contains("cannot be downgraded to model version 4.0.0")); + } + @Test void testScmInheritance() throws Exception { Model model = Model.newBuilder() diff --git a/impl/maven-core/src/test/resources/consumer/parent-with-conditions/pom.xml b/impl/maven-core/src/test/resources/consumer/parent-with-conditions/pom.xml new file mode 100644 index 000000000000..a5bd1832e989 --- /dev/null +++ b/impl/maven-core/src/test/resources/consumer/parent-with-conditions/pom.xml @@ -0,0 +1,32 @@ + + org.my.group + parent + 1.0-SNAPSHOT + pom + + + + + org.slf4j + slf4j-api + 2.0.9 + + + + + + + test-profile + + ${project.artifactId} == 'parent' + + + + org.slf4j + slf4j-api + + + + + + diff --git a/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITgh11772ConsumerPom410Test.java b/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITgh11772ConsumerPom410Test.java new file mode 100644 index 000000000000..6d2fac13eda6 --- /dev/null +++ b/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITgh11772ConsumerPom410Test.java @@ -0,0 +1,101 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 + * + * http://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.apache.maven.it; + +import java.io.File; +import java.io.Reader; +import java.nio.file.Files; +import java.nio.file.Path; + +import org.apache.maven.api.model.Model; +import org.apache.maven.model.v4.MavenStaxReader; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Integration test for GH-11772. + *

+ * Verifies that when a parent+child project uses model version 4.1.0 namespace + * (with subprojects, root), the installed consumer POMs are model version 4.0.0, + * while the build POMs retain the original 4.1.0 content. + *

+ * This ensures backward compatibility with Maven 3 and Gradle for consumer POMs + * while Maven 4 builds can resolve the full-fidelity build POM. + */ +class MavenITgh11772ConsumerPom410Test extends AbstractMavenIntegrationTestCase { + + MavenITgh11772ConsumerPom410Test() { + super("[4.0.0-rc-1,)"); + } + + private static final String GROUP_ID = "org.apache.maven.its.gh11772"; + + @Test + void testConsumerPomsAre400BuildPomsAre410() throws Exception { + File basedir = extractResources("/gh-11772-consumer-pom-410"); + + Verifier verifier = newVerifier(basedir.getAbsolutePath()); + verifier.deleteArtifacts(GROUP_ID); + verifier.addCliArguments("install"); + verifier.execute(); + verifier.verifyErrorFreeLog(); + + // Verify parent consumer POM (main artifact) is 4.0.0 + Path parentConsumerPom = + Path.of(verifier.getArtifactPath(GROUP_ID, "parent", "1.0.0-SNAPSHOT", "pom")); + assertTrue(Files.exists(parentConsumerPom), "Parent consumer POM should exist"); + Model parentConsumer = readModel(parentConsumerPom); + assertEquals("4.0.0", parentConsumer.getModelVersion(), "Parent consumer POM should be 4.0.0"); + + // Verify parent build POM retains 4.1.0 features + Path parentBuildPom = + Path.of(verifier.getArtifactPath(GROUP_ID, "parent", "1.0.0-SNAPSHOT", "pom", "build")); + assertTrue(Files.exists(parentBuildPom), "Parent build POM should exist"); + Model parentBuild = readModel(parentBuildPom); + // Build POM should retain subprojects (4.1.0 feature) + assertNotNull(parentBuild.getSubprojects(), "Build POM should retain subprojects"); + assertTrue(!parentBuild.getSubprojects().isEmpty(), "Build POM should retain subprojects"); + + // Verify child consumer POM is 4.0.0 + Path childConsumerPom = + Path.of(verifier.getArtifactPath(GROUP_ID, "child", "1.0.0-SNAPSHOT", "pom")); + assertTrue(Files.exists(childConsumerPom), "Child consumer POM should exist"); + Model childConsumer = readModel(childConsumerPom); + assertEquals("4.0.0", childConsumer.getModelVersion(), "Child consumer POM should be 4.0.0"); + + // Child consumer POM should have a parent reference (not flattened by default) + assertNotNull(childConsumer.getParent(), "Child consumer POM should have a parent reference"); + assertEquals(GROUP_ID, childConsumer.getParent().getGroupId()); + assertEquals("parent", childConsumer.getParent().getArtifactId()); + + // Verify child build POM exists + Path childBuildPom = + Path.of(verifier.getArtifactPath(GROUP_ID, "child", "1.0.0-SNAPSHOT", "pom", "build")); + assertTrue(Files.exists(childBuildPom), "Child build POM should exist"); + } + + private static Model readModel(Path pomFile) throws Exception { + try (Reader r = Files.newBufferedReader(pomFile)) { + return new MavenStaxReader().read(r); + } + } +} diff --git a/its/core-it-suite/src/test/resources/gh-11772-consumer-pom-410/child/pom.xml b/its/core-it-suite/src/test/resources/gh-11772-consumer-pom-410/child/pom.xml new file mode 100644 index 000000000000..55601e85b640 --- /dev/null +++ b/its/core-it-suite/src/test/resources/gh-11772-consumer-pom-410/child/pom.xml @@ -0,0 +1,38 @@ + + + + + + org.apache.maven.its.gh11772 + parent + 1.0.0-SNAPSHOT + + + child + pom + + + + org.slf4j + slf4j-api + + + + diff --git a/its/core-it-suite/src/test/resources/gh-11772-consumer-pom-410/pom.xml b/its/core-it-suite/src/test/resources/gh-11772-consumer-pom-410/pom.xml new file mode 100644 index 000000000000..e6061f924212 --- /dev/null +++ b/its/core-it-suite/src/test/resources/gh-11772-consumer-pom-410/pom.xml @@ -0,0 +1,41 @@ + + + + + org.apache.maven.its.gh11772 + parent + 1.0.0-SNAPSHOT + pom + + + child + + + + + + org.slf4j + slf4j-api + 2.0.9 + + + + +