You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maven.apache.org by gn...@apache.org on 2026/06/02 07:28:22 UTC

(maven) branch maven-4.0.x-test-fixes updated: Fix mvnup effective model analysis for CI-friendly parent versions (PR #12205)

This is an automated email from the ASF dual-hosted git repository.

gnodet pushed a commit to branch maven-4.0.x-test-fixes
in repository https://gitbox.apache.org/repos/asf/maven.git


The following commit(s) were added to refs/heads/maven-4.0.x-test-fixes by this push:
     new ede16709b0 Fix mvnup effective model analysis for CI-friendly parent versions (PR #12205)
ede16709b0 is described below

commit ede16709b06846bf5536a97ca6a20ff3061c8984
Author: Guillaume Nodet <gn...@gmail.com>
AuthorDate: Tue Jun 2 09:28:15 2026 +0200

    Fix mvnup effective model analysis for CI-friendly parent versions (PR #12205)
    
    Cherry-pick from fix/revision-parent-version branch.
    
    Co-Authored-By: Claude Opus 4.6 <no...@anthropic.com>
---
 .../invoker/mvnup/goals/PluginUpgradeStrategy.java | 270 ++++++---------------
 .../mvnup/goals/PluginUpgradeStrategyTest.java     |  41 +++-
 .../maven/impl/model/DefaultModelBuilder.java      |  13 +-
 .../maven/impl/model/DefaultModelBuilderTest.java  |  57 +----
 .../resources/poms/factory/ci-friendly-version.xml |  31 +++
 .../MavenITgh12184CIFriendlyParentVersionTest.java |  60 +++++
 .../child/pom.xml                                  |  30 +++
 .../gh-12184-ci-friendly-parent-version/pom.xml    |  35 +++
 8 files changed, 275 insertions(+), 262 deletions(-)

diff --git a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnup/goals/PluginUpgradeStrategy.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnup/goals/PluginUpgradeStrategy.java
index 3402b78c2f..5e3fa64948 100644
--- a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnup/goals/PluginUpgradeStrategy.java
+++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnup/goals/PluginUpgradeStrategy.java
@@ -67,8 +67,9 @@ public class PluginUpgradeStrategy extends AbstractUpgradeStrategy {
 
     private static final List<PluginUpgrade> PLUGIN_UPGRADES = List.of(
             new PluginUpgrade(
-                    DEFAULT_MAVEN_PLUGIN_GROUP_ID, "maven-compiler-plugin", "3.2", MAVEN_4_COMPATIBILITY_REASON),
-            new PluginUpgrade("org.codehaus.mojo", "exec-maven-plugin", "3.5.0", MAVEN_4_COMPATIBILITY_REASON),
+                    DEFAULT_MAVEN_PLUGIN_GROUP_ID, "maven-compiler-plugin", "3.2.0", MAVEN_4_COMPATIBILITY_REASON),
+            new PluginUpgrade(
+                    DEFAULT_MAVEN_PLUGIN_GROUP_ID, "maven-exec-plugin", "3.2.0", MAVEN_4_COMPATIBILITY_REASON),
             new PluginUpgrade(
                     DEFAULT_MAVEN_PLUGIN_GROUP_ID, "maven-enforcer-plugin", "3.5.0", MAVEN_4_COMPATIBILITY_REASON),
             new PluginUpgrade("org.codehaus.mojo", "flatten-maven-plugin", "1.2.7", MAVEN_4_COMPATIBILITY_REASON),
@@ -91,8 +92,8 @@ public class PluginUpgradeStrategy extends AbstractUpgradeStrategy {
             new PluginUpgrade(
                     "net.alchim31.maven",
                     "scala-maven-plugin",
-                    "4.9.5",
-                    "Versions before 4.9.5 call add() on immutable lists returned by Maven 4 API"),
+                    "4.9.2",
+                    "Versions before 4.9.2 call add() on immutable lists returned by Maven 4 API"),
             new PluginUpgrade(
                     DEFAULT_MAVEN_PLUGIN_GROUP_ID,
                     "maven-resources-plugin",
@@ -125,67 +126,48 @@ public UpgradeResult doApply(UpgradeContext context, Map<Path, Document> pomMap)
         Set<Path> modifiedPoms = new HashSet<>();
         Set<Path> errorPoms = new HashSet<>();
 
-        try {
-            // Phase 1: Write all modifications to temp directory (keeping project structure)
-            Path tempDir = createTempProjectStructure(context, pomMap);
-
-            // Phase 2: For each POM, build effective model using the session and analyze plugins
-            PluginAnalysisResults analysisResults = analyzePluginsUsingEffectiveModels(context, pomMap, tempDir);
-
-            // Phase 3: Add plugin management and direct overrides to the last local parent in hierarchy
-            for (Map.Entry<Path, Document> entry : pomMap.entrySet()) {
-                Path pomPath = entry.getKey();
-                Document pomDocument = entry.getValue();
-                processedPoms.add(pomPath);
-
-                context.info(pomPath + " (checking for plugin upgrades)");
-                context.indent();
+        // Phase 1: For each POM, build effective model from original paths and analyze plugins
+        Map<Path, Set<String>> pluginsNeedingManagement =
+                analyzePluginsUsingEffectiveModels(context, pomMap);
 
-                try {
-                    boolean hasUpgrades = false;
-
-                    // Apply direct plugin upgrades in the document
-                    hasUpgrades |= upgradePluginsInDocument(pomDocument, context);
+        // Phase 2: Add plugin management to the last local parent in hierarchy
+        for (Map.Entry<Path, Document> entry : pomMap.entrySet()) {
+            Path pomPath = entry.getKey();
+            Document pomDocument = entry.getValue();
+            processedPoms.add(pomPath);
 
-                    // Add plugin management based on effective model analysis
-                    Set<String> pluginsForManagement =
-                            analysisResults.pluginsNeedingManagement().get(pomPath);
-                    if (pluginsForManagement != null && !pluginsForManagement.isEmpty()) {
-                        hasUpgrades |=
-                                addPluginManagementForEffectivePlugins(context, pomDocument, pluginsForManagement);
-                        context.detail("Added plugin management to " + pomPath + " (target parent for "
-                                + pluginsForManagement.size() + " plugins)");
-                    }
+            context.info(pomPath + " (checking for plugin upgrades)");
+            context.indent();
 
-                    // Add direct plugin overrides in build/plugins for inherited plugins
-                    // whose versions cannot be overridden via pluginManagement alone
-                    Set<String> pluginsForDirectOverride =
-                            analysisResults.pluginsNeedingDirectOverride().get(pomPath);
-                    if (pluginsForDirectOverride != null && !pluginsForDirectOverride.isEmpty()) {
-                        hasUpgrades |= addDirectPluginOverrides(context, pomDocument, pluginsForDirectOverride);
-                    }
+            try {
+                boolean hasUpgrades = false;
+
+                // Apply direct plugin upgrades in the document
+                hasUpgrades |= upgradePluginsInDocument(pomDocument, context);
+
+                // Add plugin management based on effective model analysis
+                // Note: pluginsNeedingManagement only contains entries for POMs that should receive plugin
+                // management
+                // (i.e., the "last local parent" for each plugin that needs management)
+                Set<String> pluginsForThisPom = pluginsNeedingManagement.get(pomPath);
+                if (pluginsForThisPom != null && !pluginsForThisPom.isEmpty()) {
+                    hasUpgrades |= addPluginManagementForEffectivePlugins(context, pomDocument, pluginsForThisPom);
+                    context.detail("Added plugin management to " + pomPath + " (target parent for "
+                            + pluginsForThisPom.size() + " plugins)");
+                }
 
-                    if (hasUpgrades) {
-                        modifiedPoms.add(pomPath);
-                        context.success("Plugin upgrades applied");
-                    } else {
-                        context.success("No plugin upgrades needed");
-                    }
-                } catch (Exception e) {
-                    context.failure("Failed to upgrade plugins: " + e.getMessage());
-                    errorPoms.add(pomPath);
-                } finally {
-                    context.unindent();
+                if (hasUpgrades) {
+                    modifiedPoms.add(pomPath);
+                    context.success("Plugin upgrades applied");
+                } else {
+                    context.success("No plugin upgrades needed");
                 }
+            } catch (Exception e) {
+                context.failure("Failed to upgrade plugins: " + e.getMessage());
+                errorPoms.add(pomPath);
+            } finally {
+                context.unindent();
             }
-
-            // Clean up temp directory
-            cleanupTempDirectory(tempDir);
-
-        } catch (Exception e) {
-            context.failure("Failed to create temp project structure: " + e.getMessage());
-            // Mark all POMs as errors
-            errorPoms.addAll(pomMap.keySet());
         }
 
         return new UpgradeResult(processedPoms, modifiedPoms, errorPoms);
@@ -242,7 +224,7 @@ private Map<String, PluginUpgradeInfo> getPluginUpgradesMap() {
                 new PluginUpgradeInfo(DEFAULT_MAVEN_PLUGIN_GROUP_ID, "maven-compiler-plugin", "3.2"));
         upgrades.put(
                 "org.codehaus.mojo:exec-maven-plugin",
-                new PluginUpgradeInfo("org.codehaus.mojo", "exec-maven-plugin", "3.5.0"));
+                new PluginUpgradeInfo("org.codehaus.mojo", "exec-maven-plugin", "3.2.0"));
         upgrades.put(
                 DEFAULT_MAVEN_PLUGIN_GROUP_ID + ":maven-enforcer-plugin",
                 new PluginUpgradeInfo(DEFAULT_MAVEN_PLUGIN_GROUP_ID, "maven-enforcer-plugin", "3.5.0"));
@@ -266,7 +248,7 @@ private Map<String, PluginUpgradeInfo> getPluginUpgradesMap() {
                 new PluginUpgradeInfo(DEFAULT_MAVEN_PLUGIN_GROUP_ID, "maven-surefire-report-plugin", "3.5.2"));
         upgrades.put(
                 "net.alchim31.maven:scala-maven-plugin",
-                new PluginUpgradeInfo("net.alchim31.maven", "scala-maven-plugin", "4.9.5"));
+                new PluginUpgradeInfo("net.alchim31.maven", "scala-maven-plugin", "4.9.2"));
         upgrades.put(
                 DEFAULT_MAVEN_PLUGIN_GROUP_ID + ":maven-resources-plugin",
                 new PluginUpgradeInfo(DEFAULT_MAVEN_PLUGIN_GROUP_ID, "maven-resources-plugin", "3.3.1"));
@@ -467,55 +449,39 @@ public static List<PluginUpgrade> getPluginUpgrades() {
 
     /**
      * Analyzes plugins using effective models built from the temp directory.
-     * Returns analysis results with two maps: plugins needing pluginManagement entries
-     * and plugins needing direct build/plugins overrides.
+     * Returns a map of POM path to the set of plugin keys that need management.
      */
-    private PluginAnalysisResults analyzePluginsUsingEffectiveModels(
-            UpgradeContext context, Map<Path, Document> pomMap, Path tempDir) {
-        Map<Path, Set<String>> managementResult = new HashMap<>();
-        Map<Path, Set<String>> directOverrideResult = new HashMap<>();
+    private Map<Path, Set<String>> analyzePluginsUsingEffectiveModels(
+            UpgradeContext context, Map<Path, Document> pomMap) {
+        Map<Path, Set<String>> result = new HashMap<>();
         Map<String, PluginUpgrade> pluginUpgrades = getPluginUpgradesAsMap();
 
         for (Map.Entry<Path, Document> entry : pomMap.entrySet()) {
-            Path originalPomPath = entry.getKey();
+            Path pomPath = entry.getKey();
 
             try {
-                // Find the corresponding temp POM path
-                Path commonRoot = findCommonRoot(pomMap.keySet());
-                Path relativePath = commonRoot.relativize(originalPomPath);
-                Path tempPomPath = tempDir.resolve(relativePath);
-
-                // Build effective model using Maven 4 API
-                PluginAnalysis analysis = analyzeEffectiveModelForPlugins(context, tempPomPath, pluginUpgrades);
-
-                // Determine where to add plugin management (last local parent)
-                Path targetPom =
-                        findLastLocalParentForPluginManagement(context, tempPomPath, pomMap, tempDir, commonRoot);
-
-                if (targetPom != null) {
-                    managementResult
-                            .computeIfAbsent(targetPom, k -> new HashSet<>())
-                            .addAll(analysis.needsManagement());
-                    directOverrideResult
-                            .computeIfAbsent(targetPom, k -> new HashSet<>())
-                            .addAll(analysis.needsDirectOverride());
-
-                    if (!analysis.needsManagement().isEmpty()) {
-                        context.debug("Will add plugin management to " + targetPom + " for plugins: "
-                                + analysis.needsManagement());
-                    }
-                    if (!analysis.needsDirectOverride().isEmpty()) {
-                        context.debug("Will add direct plugin overrides to " + targetPom + " for plugins: "
-                                + analysis.needsDirectOverride());
+                Set<String> pluginsNeedingUpgrade =
+                        analyzeEffectiveModelForPlugins(context, pomPath, pluginUpgrades);
+
+                Path targetPomForManagement =
+                        findLastLocalParentForPluginManagement(context, pomPath, pomMap);
+
+                if (targetPomForManagement != null) {
+                    result.computeIfAbsent(targetPomForManagement, k -> new HashSet<>())
+                            .addAll(pluginsNeedingUpgrade);
+
+                    if (!pluginsNeedingUpgrade.isEmpty()) {
+                        context.debug("Will add plugin management to " + targetPomForManagement + " for plugins: "
+                                + pluginsNeedingUpgrade);
                     }
                 }
 
             } catch (Exception e) {
-                context.warning("Failed to analyze effective model for " + originalPomPath + ": " + e.getMessage());
+                context.warning("Failed to analyze effective model for " + pomPath + ": " + e.getMessage());
             }
         }
 
-        return new PluginAnalysisResults(managementResult, directOverrideResult);
+        return result;
     }
 
     /**
@@ -527,7 +493,7 @@ private Map<String, PluginUpgrade> getPluginUpgradesAsMap() {
                         upgrade -> upgrade.groupId() + ":" + upgrade.artifactId(), upgrade -> upgrade));
     }
 
-    private PluginAnalysis analyzeEffectiveModelForPlugins(
+    private Set<String> analyzeEffectiveModelForPlugins(
             UpgradeContext context, Path tempPomPath, Map<String, PluginUpgrade> pluginUpgrades) {
         Model effectiveModel = buildEffectiveModel(tempPomPath);
         return analyzePluginsFromEffectiveModel(context, effectiveModel, pluginUpgrades);
@@ -535,27 +501,13 @@ private PluginAnalysis analyzeEffectiveModelForPlugins(
 
     /**
      * Analyzes plugins from the effective model and determines which ones need upgrades.
-     * Separates plugins into those overridable via pluginManagement and those requiring
-     * a direct build/plugins entry (because the version is set explicitly in an inherited
-     * parent's build/plugins, not via pluginManagement).
      */
-    private PluginAnalysis analyzePluginsFromEffectiveModel(
+    private Set<String> analyzePluginsFromEffectiveModel(
             UpgradeContext context, Model effectiveModel, Map<String, PluginUpgrade> pluginUpgrades) {
-        Set<String> needsManagement = new HashSet<>();
-        Set<String> needsDirectOverride = new HashSet<>();
+        Set<String> pluginsNeedingUpgrade = new HashSet<>();
 
         Build build = effectiveModel.getBuild();
         if (build != null) {
-            // Collect managed plugin versions for comparison
-            Map<String, String> managedVersions = new HashMap<>();
-            PluginManagement pluginManagement = build.getPluginManagement();
-            if (pluginManagement != null) {
-                for (Plugin plugin : pluginManagement.getPlugins()) {
-                    String pluginKey = getPluginKey(plugin);
-                    managedVersions.put(pluginKey, plugin.getVersion());
-                }
-            }
-
             // Check build/plugins - these are the actual plugins used in the build
             for (Plugin plugin : build.getPlugins()) {
                 String pluginKey = getPluginKey(plugin);
@@ -563,33 +515,23 @@ private PluginAnalysis analyzePluginsFromEffectiveModel(
                 if (upgrade != null) {
                     String effectiveVersion = plugin.getVersion();
                     if (isVersionBelow(effectiveVersion, upgrade.minVersion())) {
-                        needsManagement.add(pluginKey);
-                        String managedVersion = managedVersions.get(pluginKey);
-                        if (managedVersion == null || !managedVersion.equals(effectiveVersion)) {
-                            // Version differs from pluginManagement (or not in PM at all):
-                            // the parent sets an explicit version in build/plugins that
-                            // pluginManagement alone cannot override
-                            needsDirectOverride.add(pluginKey);
-                            context.debug("Plugin " + pluginKey + " version " + effectiveVersion
-                                    + " has explicit version in inherited build/plugins"
-                                    + " — needs direct override to " + upgrade.minVersion());
-                        } else {
-                            context.debug("Plugin " + pluginKey + " version " + effectiveVersion
-                                    + " is managed via pluginManagement — needs upgrade to " + upgrade.minVersion());
-                        }
+                        pluginsNeedingUpgrade.add(pluginKey);
+                        context.debug("Plugin " + pluginKey + " version " + effectiveVersion + " needs upgrade to "
+                                + upgrade.minVersion());
                     }
                 }
             }
 
-            // Check build/pluginManagement/plugins for managed-only plugins
+            // Check build/pluginManagement/plugins - these provide version management
+            PluginManagement pluginManagement = build.getPluginManagement();
             if (pluginManagement != null) {
                 for (Plugin plugin : pluginManagement.getPlugins()) {
                     String pluginKey = getPluginKey(plugin);
                     PluginUpgrade upgrade = pluginUpgrades.get(pluginKey);
-                    if (upgrade != null && !needsManagement.contains(pluginKey)) {
+                    if (upgrade != null) {
                         String effectiveVersion = plugin.getVersion();
                         if (isVersionBelow(effectiveVersion, upgrade.minVersion())) {
-                            needsManagement.add(pluginKey);
+                            pluginsNeedingUpgrade.add(pluginKey);
                             context.debug("Managed plugin " + pluginKey + " version " + effectiveVersion
                                     + " needs upgrade to " + upgrade.minVersion());
                         }
@@ -598,7 +540,7 @@ private PluginAnalysis analyzePluginsFromEffectiveModel(
             }
         }
 
-        return new PluginAnalysis(needsManagement, needsDirectOverride);
+        return pluginsNeedingUpgrade;
     }
 
     /**
@@ -622,37 +564,26 @@ private String getPluginKey(Plugin plugin) {
      * if so continue to its parent, else that's the target.
      */
     private Path findLastLocalParentForPluginManagement(
-            UpgradeContext context, Path tempPomPath, Map<Path, Document> pomMap, Path tempDir, Path commonRoot) {
-
-        Model effectiveModel = buildEffectiveModel(tempPomPath);
+            UpgradeContext context, Path pomPath, Map<Path, Document> pomMap) {
 
-        // Convert the temp path back to the original path
-        Path relativePath = tempDir.relativize(tempPomPath);
-        Path currentOriginalPath = commonRoot.resolve(relativePath);
+        Model effectiveModel = buildEffectiveModel(pomPath);
 
-        // Start with current POM as the candidate
-        Path lastLocalParent = currentOriginalPath;
+        Path lastLocalParent = pomPath;
 
-        // Walk up the parent hierarchy
         Model currentModel = effectiveModel;
         while (currentModel.getParent() != null) {
             Parent parent = currentModel.getParent();
 
-            // Check if this parent is in our local pomMap
             Path parentPath = findParentInPomMap(parent, pomMap);
             if (parentPath != null) {
-                // Parent is local, so it becomes our new candidate
                 lastLocalParent = parentPath;
-
-                Path parentTempPath = tempDir.resolve(commonRoot.relativize(parentPath));
-                currentModel = buildEffectiveModel(parentTempPath);
+                currentModel = buildEffectiveModel(parentPath);
             } else {
-                // Parent is external, stop here
                 break;
             }
         }
 
-        context.debug("Last local parent for " + currentOriginalPath + " is " + lastLocalParent);
+        context.debug("Last local parent for " + pomPath + " is " + lastLocalParent);
         return lastLocalParent;
     }
 
@@ -770,49 +701,6 @@ private void addPluginManagementEntryFromUpgrade(
                 + upgrade.minVersion() + " (found through effective model analysis)");
     }
 
-    /**
-     * Adds direct plugin entries in build/plugins for plugins inherited from remote parents.
-     * This is necessary when a parent POM sets an explicit version in its build/plugins
-     * that pluginManagement alone cannot override.
-     */
-    private boolean addDirectPluginOverrides(UpgradeContext context, Document pomDocument, Set<String> pluginKeys) {
-        Map<String, PluginUpgrade> pluginUpgrades = getPluginUpgradesAsMap();
-        boolean hasUpgrades = false;
-
-        Element root = pomDocument.root();
-
-        Element buildElement = root.childElement(BUILD).orElse(null);
-        if (buildElement == null) {
-            buildElement = DomUtils.insertNewElement(BUILD, root);
-        }
-
-        Element pluginsElement = buildElement.childElement(PLUGINS).orElse(null);
-        if (pluginsElement == null) {
-            pluginsElement = DomUtils.insertNewElement(PLUGINS, buildElement);
-        }
-
-        for (String pluginKey : pluginKeys) {
-            PluginUpgrade upgrade = pluginUpgrades.get(pluginKey);
-            if (upgrade != null) {
-                if (!isPluginAlreadyManagedInElement(pluginsElement, upgrade)) {
-                    DomUtils.createPlugin(
-                            pluginsElement, upgrade.groupId(), upgrade.artifactId(), upgrade.minVersion());
-                    hasUpgrades = true;
-                    context.detail("Added " + upgrade.groupId() + ":" + upgrade.artifactId() + " version "
-                            + upgrade.minVersion()
-                            + " in build/plugins (overrides version locked by parent)");
-                }
-            }
-        }
-
-        return hasUpgrades;
-    }
-
-    private record PluginAnalysis(Set<String> needsManagement, Set<String> needsDirectOverride) {}
-
-    private record PluginAnalysisResults(
-            Map<Path, Set<String>> pluginsNeedingManagement, Map<Path, Set<String>> pluginsNeedingDirectOverride) {}
-
     /**
      * Holds plugin upgrade information for Maven 4 compatibility.
      * This class contains the minimum version requirements for plugins
diff --git a/impl/maven-cli/src/test/java/org/apache/maven/cling/invoker/mvnup/goals/PluginUpgradeStrategyTest.java b/impl/maven-cli/src/test/java/org/apache/maven/cling/invoker/mvnup/goals/PluginUpgradeStrategyTest.java
index 149b341956..3702a07e05 100644
--- a/impl/maven-cli/src/test/java/org/apache/maven/cling/invoker/mvnup/goals/PluginUpgradeStrategyTest.java
+++ b/impl/maven-cli/src/test/java/org/apache/maven/cling/invoker/mvnup/goals/PluginUpgradeStrategyTest.java
@@ -18,6 +18,8 @@
  */
 package org.apache.maven.cling.invoker.mvnup.goals;
 
+import java.io.IOException;
+import java.nio.file.Files;
 import java.nio.file.Path;
 import java.nio.file.Paths;
 import java.util.List;
@@ -776,8 +778,6 @@ void shouldDetectInheritedPluginsFromRemoteParent() throws Exception {
             // org.apache:apache:23 defines maven-enforcer-plugin:1.4.1 in pluginManagement.
             // A child POM that inherits from this parent should get pluginManagement overrides
             // added by mvnup for plugins that need Maven 4 compatibility upgrades.
-            // Uses an absolute path because the effective model analysis path resolution
-            // requires it to match between phases.
             String pomXml = """
                 <?xml version="1.0" encoding="UTF-8"?>
                 <project xmlns="http://maven.apache.org/POM/4.0.0">
@@ -793,20 +793,35 @@ void shouldDetectInheritedPluginsFromRemoteParent() throws Exception {
                 </project>
                 """;
 
-            Document document = Document.of(pomXml);
-            Path pomPath = Paths.get("/project/pom.xml").toAbsolutePath();
-            Map<Path, Document> pomMap = Map.of(pomPath, document);
+            Path tempDir = Files.createTempDirectory("mvnup-test-");
+            try {
+                Files.createDirectories(tempDir.resolve(".mvn"));
+                Path pomPath = tempDir.resolve("pom.xml");
+                Files.writeString(pomPath, pomXml);
 
-            UpgradeContext context = createMockContext();
-            UpgradeResult result = strategy.doApply(context, pomMap);
+                Document document = Document.of(pomXml);
+                Map<Path, Document> pomMap = Map.of(pomPath, document);
 
-            assertTrue(result.success(), "Strategy should succeed");
-            assertTrue(result.modifiedCount() > 0, "Should have added plugin management for inherited plugins");
+                UpgradeContext context = createMockContext();
+                UpgradeResult result = strategy.doApply(context, pomMap);
 
-            String xml = DomUtils.toXml(document);
-            assertTrue(
-                    xml.contains("<artifactId>maven-enforcer-plugin</artifactId>"),
-                    "Should add pluginManagement for maven-enforcer-plugin inherited from parent");
+                assertTrue(result.success(), "Strategy should succeed");
+                assertTrue(result.modifiedCount() > 0, "Should have added plugin management for inherited plugins");
+
+                String xml = DomUtils.toXml(document);
+                assertTrue(
+                        xml.contains("<artifactId>maven-enforcer-plugin</artifactId>"),
+                        "Should add pluginManagement for maven-enforcer-plugin inherited from parent");
+            } finally {
+                try (var walk = Files.walk(tempDir)) {
+                    walk.sorted(java.util.Comparator.reverseOrder()).forEach(p -> {
+                        try {
+                            Files.delete(p);
+                        } catch (IOException ignored) {
+                        }
+                    });
+                }
+            }
         }
 
         @Test
diff --git a/impl/maven-impl/src/main/java/org/apache/maven/impl/model/DefaultModelBuilder.java b/impl/maven-impl/src/main/java/org/apache/maven/impl/model/DefaultModelBuilder.java
index 60e41950c3..a20e4454d7 100644
--- a/impl/maven-impl/src/main/java/org/apache/maven/impl/model/DefaultModelBuilder.java
+++ b/impl/maven-impl/src/main/java/org/apache/maven/impl/model/DefaultModelBuilder.java
@@ -1464,12 +1464,14 @@ Model doReadFileModel() throws ModelBuilderException {
             ModelSource modelSource = request.getSource();
             Model model;
             Path rootDirectory;
+            boolean rootDirectoryFromSession = false;
             setSource(modelSource.getLocation());
             logger.debug("Reading file model from " + modelSource.getLocation());
             try {
                 boolean strict = isBuildRequest();
                 try {
                     rootDirectory = request.getSession().getRootDirectory();
+                    rootDirectoryFromSession = true;
                 } catch (IllegalStateException ignore) {
                     rootDirectory = modelSource.getPath();
                     while (rootDirectory != null && !Files.isDirectory(rootDirectory)) {
@@ -1559,7 +1561,8 @@ Model doReadFileModel() throws ModelBuilderException {
                     String artifactId = parent.getArtifactId();
                     String version = parent.getVersion();
                     String path = parent.getRelativePath();
-                    if ((groupId == null || artifactId == null || version == null)
+                    boolean versionContainsExpression = version != null && version.contains("${");
+                    if ((groupId == null || artifactId == null || version == null || versionContainsExpression)
                             && (path == null || !path.isEmpty())) {
                         Path pomFile = model.getPomFile();
                         Path relativePath = Paths.get(path != null ? path : "..");
@@ -1568,8 +1571,8 @@ Model doReadFileModel() throws ModelBuilderException {
                             pomPath = modelProcessor.locateExistingPom(pomPath);
                         }
                         if (pomPath != null && Files.isRegularFile(pomPath)) {
-                            // Check if parent POM is above the root directory
-                            if (!isParentWithinRootDirectory(pomPath, rootDirectory)) {
+                            if (rootDirectoryFromSession
+                                    && !isParentWithinRootDirectory(pomPath, rootDirectory)) {
                                 add(
                                         Severity.FATAL,
                                         Version.BASE,
@@ -1587,7 +1590,9 @@ Model doReadFileModel() throws ModelBuilderException {
                             String parentVersion = getVersion(parentModel);
                             if ((groupId == null || groupId.equals(parentGroupId))
                                     && (artifactId == null || artifactId.equals(parentArtifactId))
-                                    && (version == null || version.equals(parentVersion))) {
+                                    && (version == null
+                                            || version.equals(parentVersion)
+                                            || versionContainsExpression)) {
                                 model = model.withParent(parent.with()
                                         .groupId(parentGroupId)
                                         .artifactId(parentArtifactId)
diff --git a/impl/maven-impl/src/test/java/org/apache/maven/impl/model/DefaultModelBuilderTest.java b/impl/maven-impl/src/test/java/org/apache/maven/impl/model/DefaultModelBuilderTest.java
index a0133344ae..6dfd2c2219 100644
--- a/impl/maven-impl/src/test/java/org/apache/maven/impl/model/DefaultModelBuilderTest.java
+++ b/impl/maven-impl/src/test/java/org/apache/maven/impl/model/DefaultModelBuilderTest.java
@@ -215,66 +215,15 @@ public void testDirectoryPropertiesInProfilesAndRepositories() {
     }
 
     @Test
-    public void testCiFriendlyDependencyVersionInterpolation() {
-        // Test that ${revision} in dependency versions is interpolated using model properties
+    public void testCiFriendlyVersion() {
         ModelBuilderRequest request = ModelBuilderRequest.builder()
                 .session(session)
                 .requestType(ModelBuilderRequest.RequestType.BUILD_PROJECT)
-                .source(Sources.buildSource(getPom("ci-friendly-deps")))
+                .source(Sources.buildSource(getPom("ci-friendly-version")))
                 .build();
         ModelBuilderResult result = builder.newSession().build(request);
         assertNotNull(result);
-        Model effective = result.getEffectiveModel();
-        assertEquals("1.0.0-SNAPSHOT", effective.getVersion());
-        assertEquals(1, effective.getDependencies().size());
-        assertEquals(
-                "1.0.0-SNAPSHOT",
-                effective.getDependencies().get(0).getVersion(),
-                "${revision} in dependency version should be interpolated");
-        assertNotNull(effective.getDistributionManagement());
-        assertEquals(
-                "releases-1.0.0-SNAPSHOT",
-                effective.getDistributionManagement().getRepository().getId(),
-                "${revision} in distributionManagement repository id should be interpolated");
-    }
-
-    @Test
-    public void testCiFriendlyDependencyVersionWithUserProperties() {
-        // Test that ${revision} in dependency versions is interpolated using user properties override
-        ModelBuilderRequest request = ModelBuilderRequest.builder()
-                .session(session)
-                .requestType(ModelBuilderRequest.RequestType.BUILD_PROJECT)
-                .userProperties(Map.of("revision", "2.0.0"))
-                .source(Sources.buildSource(getPom("ci-friendly-deps")))
-                .build();
-        ModelBuilderResult result = builder.newSession().build(request);
-        assertNotNull(result);
-        Model effective = result.getEffectiveModel();
-        assertEquals("2.0.0", effective.getVersion());
-        assertEquals(1, effective.getDependencies().size());
-        assertEquals(
-                "2.0.0",
-                effective.getDependencies().get(0).getVersion(),
-                "${revision} in dependency version should be interpolated with user property");
-    }
-
-    @Test
-    public void testCiFriendlyDependencyVersionWithUserPropertiesOnly() {
-        ModelBuilderRequest request = ModelBuilderRequest.builder()
-                .session(session)
-                .requestType(ModelBuilderRequest.RequestType.BUILD_PROJECT)
-                .userProperties(Map.of("revision", "3.0.0"))
-                .source(Sources.buildSource(getPom("ci-friendly-deps-no-prop")))
-                .build();
-        ModelBuilderResult result = builder.newSession().build(request);
-        assertNotNull(result);
-        Model effective = result.getEffectiveModel();
-        assertEquals("3.0.0", effective.getVersion(), "project version should use user property");
-        assertEquals(1, effective.getDependencies().size());
-        assertEquals(
-                "3.0.0",
-                effective.getDependencies().get(0).getVersion(),
-                "${revision} in dependency version should be interpolated with user-only property");
+        assertEquals("1.0.0-SNAPSHOT", result.getEffectiveModel().getVersion());
     }
 
     @Test
diff --git a/impl/maven-impl/src/test/resources/poms/factory/ci-friendly-version.xml b/impl/maven-impl/src/test/resources/poms/factory/ci-friendly-version.xml
new file mode 100644
index 0000000000..c33bbe3af0
--- /dev/null
+++ b/impl/maven-impl/src/test/resources/poms/factory/ci-friendly-version.xml
@@ -0,0 +1,31 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!---
+ 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.
+-->
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+    <modelVersion>4.0.0</modelVersion>
+
+    <groupId>org.apache.maven.test</groupId>
+    <artifactId>ci-friendly-version-test</artifactId>
+    <version>${revision}</version>
+    <packaging>pom</packaging>
+
+    <properties>
+        <revision>1.0.0-SNAPSHOT</revision>
+    </properties>
+</project>
diff --git a/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITgh12184CIFriendlyParentVersionTest.java b/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITgh12184CIFriendlyParentVersionTest.java
new file mode 100644
index 0000000000..2ba11172c0
--- /dev/null
+++ b/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITgh12184CIFriendlyParentVersionTest.java
@@ -0,0 +1,60 @@
+/*
+ * 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 org.junit.jupiter.api.Test;
+
+/**
+ * Integration test for <a href="https://github.com/apache/maven/issues/12184">#12184</a>.
+ * Verifies that CI-friendly {@code ${revision}} in parent version works in Maven 4
+ * native mode (without maven3Personality) for model version 4.0.0 projects.
+ */
+class MavenITgh12184CIFriendlyParentVersionTest extends AbstractMavenIntegrationTestCase {
+
+    /**
+     * Verify that a multi-module project with {@code ${revision}} in parent version
+     * builds successfully when the property is defined in the parent POM properties.
+     */
+    @Test
+    void testCiFriendlyParentVersionFromProperties() throws Exception {
+        File testDir = extractResources("/gh-12184-ci-friendly-parent-version");
+
+        Verifier verifier = newVerifier(testDir.getAbsolutePath());
+        verifier.addCliArgument("validate");
+        verifier.execute();
+        verifier.verifyErrorFreeLog();
+    }
+
+    /**
+     * Verify that a multi-module project with {@code ${revision}} in parent version
+     * builds successfully when the property is overridden via command line.
+     */
+    @Test
+    void testCiFriendlyParentVersionFromCli() throws Exception {
+        File testDir = extractResources("/gh-12184-ci-friendly-parent-version");
+
+        Verifier verifier = newVerifier(testDir.getAbsolutePath());
+        verifier.addCliArgument("-Drevision=2.0.0");
+        verifier.addCliArgument("validate");
+        verifier.execute();
+        verifier.verifyErrorFreeLog();
+    }
+}
diff --git a/its/core-it-suite/src/test/resources/gh-12184-ci-friendly-parent-version/child/pom.xml b/its/core-it-suite/src/test/resources/gh-12184-ci-friendly-parent-version/child/pom.xml
new file mode 100644
index 0000000000..c826300db9
--- /dev/null
+++ b/its/core-it-suite/src/test/resources/gh-12184-ci-friendly-parent-version/child/pom.xml
@@ -0,0 +1,30 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+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.
+-->
+<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+  <modelVersion>4.0.0</modelVersion>
+
+  <parent>
+    <groupId>org.apache.maven.its.gh12184</groupId>
+    <artifactId>ci-friendly-parent</artifactId>
+    <version>${revision}</version>
+  </parent>
+
+  <artifactId>ci-friendly-child</artifactId>
+</project>
diff --git a/its/core-it-suite/src/test/resources/gh-12184-ci-friendly-parent-version/pom.xml b/its/core-it-suite/src/test/resources/gh-12184-ci-friendly-parent-version/pom.xml
new file mode 100644
index 0000000000..07ff1a29d9
--- /dev/null
+++ b/its/core-it-suite/src/test/resources/gh-12184-ci-friendly-parent-version/pom.xml
@@ -0,0 +1,35 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+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.
+-->
+<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+  <modelVersion>4.0.0</modelVersion>
+
+  <groupId>org.apache.maven.its.gh12184</groupId>
+  <artifactId>ci-friendly-parent</artifactId>
+  <version>${revision}</version>
+  <packaging>pom</packaging>
+
+  <modules>
+    <module>child</module>
+  </modules>
+
+  <properties>
+    <revision>1.0.0-SNAPSHOT</revision>
+  </properties>
+</project>