[MNG-5359] Do not activate pluginManagement executions through lifecycle bindings - #13041
[MNG-5359] Do not activate pluginManagement executions through lifecycle bindings#13041goutamadwant wants to merge 2 commits into
Conversation
When lifecycle bindings introduce a plugin, apply only its managed version and configuration. Keep lifecycle executions and other plugin-level fields unchanged so pluginManagement remains passive until a plugin is explicitly declared. Cover both model implementations with focused merger tests and add a Core IT with an explicit-plugin positive control.
|
Please provide an IT that reproduces the problem, it's way easier to understand the exact problem. |
Filter only pluginManagement executions bound to a different lifecycle when lifecycle bindings introduce a plugin. This retains phase-less and same-lifecycle executions, preserving MNG-4344 behavior while preventing cross-lifecycle activation.
|
Thanks @gnodet. The PR includes While validating the fix, I found that filtering all managed executions regressed MNG-4344. The follow-up now filters only executions bound to a different lifecycle while retaining same-lifecycle and phase-less executions. Validated with the focused lifecycle injector tests and both |
gnodet
left a comment
There was a problem hiding this comment.
Thanks for tackling this long-standing issue. The approach of filtering cross-lifecycle managed executions while preserving same-lifecycle ones is sound and addresses MNG-5359 without regressing MNG-4344. Tests are solid with good positive/negative controls.
A few observations below.
This review was generated by an AI agent, Hermès on behalf of @gnodet.
| lifecycleModel.getBuild().getPlugins().addAll(defaultPlugins); | ||
|
|
||
| merger.merge(model, lifecycleModel); | ||
| new LifecycleBindingsMerger(getPhaseToLifecycleMap()).merge(model, lifecycleModel); |
There was a problem hiding this comment.
💡 Performance: getPhaseToLifecycleMap() is called on every injectLifecycleBindings invocation, creating a new HashMap and a new LifecycleBindingsMerger each time. The previous code cached a single LifecycleBindingsMerger as a field.
Since the phase-to-lifecycle map doesn't change after startup, this could be computed once in the constructor:
| new LifecycleBindingsMerger(getPhaseToLifecycleMap()).merge(model, lifecycleModel); | |
| new LifecycleBindingsMerger(phaseToLifecycleMap).merge(model, lifecycleModel); |
…with phaseToLifecycleMap as a final field initialized in the constructor. Not critical — model building isn't in a tight loop — but it's a free optimization.
| private boolean isFromSameLifecycle(Plugin lifecyclePlugin, PluginExecution managedExecution) { | ||
| String managedPhase = managedExecution.getPhase(); | ||
| if (managedPhase == null) { | ||
| return true; | ||
| } | ||
|
|
||
| String managedLifecycle = phaseToLifecycle.get(managedPhase); | ||
| return lifecyclePlugin.getExecutions().stream() | ||
| .anyMatch(execution -> managedPhase.equals(execution.getPhase()) | ||
| || managedLifecycle != null | ||
| && managedLifecycle.equals(phaseToLifecycle.get(execution.getPhase()))); | ||
| } |
There was a problem hiding this comment.
📝 Edge case to consider: When managedPhase is a custom/unknown phase not in phaseToLifecycleMap, managedLifecycle is null and the logic falls back to exact phase matching only. This means a managed execution bound to a custom phase (e.g. from a lifecycle extension) will be filtered out unless a lifecycle execution is bound to the exact same phase.
Is this the intended behavior? It seems reasonable (err on the side of not activating unknown phases), but worth documenting as a conscious design decision, especially since custom lifecycle extensions exist in the wild.
| } | ||
|
|
||
| private Map<String, String> getPhaseToLifecycleMap() { | ||
| Map<String, String> phaseToLifecycle = new HashMap<>(); | ||
| lifecycleRegistry.stream().forEach(lifecycle -> { | ||
| lifecycleRegistry.computePhases(lifecycle).forEach(phase -> phaseToLifecycle.put(phase, lifecycle.id())); | ||
| lifecycle.aliases().forEach(alias -> phaseToLifecycle.put(alias.v3Phase(), lifecycle.id())); |
There was a problem hiding this comment.
📝 Consistency with legacy model: The legacy model's getPhaseToLifecycleMap() delegates to DefaultLifecycles.getPhaseToLifecycleMap(), while the Maven 4 model implementation computes the map directly from LifecycleRegistry including aliases. This means the two implementations might produce different maps if alias handling differs.
This is likely fine (the legacy model doesn't have Maven 4 lifecycle aliases), but a comment explaining the difference would help future maintainers.
| assertEquals( | ||
| "lifecycle", | ||
| result.getExecutions().stream() | ||
| .filter(execution -> "default-clean".equals(execution.getId())) |
There was a problem hiding this comment.
💡 Nit: The test verifies the result has 3 executions and checks the expected set, but doesn't verify that the filtered execution (managed-initialize) is absent. While the Set.of(...) assertion implicitly covers this (3 elements, none is managed-initialize), an explicit assertFalse would make the intent clearer:
assertFalse(result.getExecutions().stream()
.anyMatch(e -> "managed-initialize".equals(e.getId())),
"Cross-lifecycle managed execution should be filtered out");| verifier.setAutoclean(false); | ||
| verifier.deleteDirectory("target"); | ||
| verifier.addCliArgument("package"); | ||
| verifier.execute(); | ||
| verifier.verifyErrorFreeLog(); | ||
| verifier.verifyFileNotPresent("target/managed-clean.txt"); | ||
|
|
||
| verifier = newVerifier(testDir); | ||
| verifier.setAutoclean(false); | ||
| verifier.deleteDirectory("target"); | ||
| verifier.addCliArgument("-Pactivate-clean-plugin"); | ||
| verifier.addCliArgument("package"); | ||
| verifier.execute(); | ||
| verifier.verifyErrorFreeLog(); | ||
| verifier.verifyFilePresent("target/managed-clean.txt"); | ||
| } |
There was a problem hiding this comment.
💡 Suggestion: Both test phases could use separate test methods (e.g., testManagedExecutionNotActivatedWithoutDeclaration and testManagedExecutionActivatedWithExplicitDeclaration) for clearer test isolation and failure reporting. If one phase fails, you immediately know which scenario broke.
Also, the test uses package as the target phase, but the managed execution is also bound to package — so the test verifies that a managed clean plugin execution bound to package (a default lifecycle phase) is NOT activated when the clean plugin is introduced only via lifecycle bindings (clean lifecycle). This is a good cross-lifecycle test. A brief comment explaining this would help readability.
Fixes #6918.
Problem
When lifecycle bindings introduce a plugin that also appears in
pluginManagement, Maven currently clones and merges the entire managed plugin. This makespluginManagementactive by copying managed executions into the lifecycle plugin, even though the plugin was never declared inbuild/plugins.On current
master, amaven-clean-pluginexecution namedcustom-clean, declared only inpluginManagementand bound toinitialize, is executed bymvn initialize.Change
This preserves the existing behavior where lifecycle plugins obtain their version and configuration from
pluginManagement, while keeping managed executions passive until the plugin is explicitly declared.Tests
MavenITmng5359PluginManagementExecutionTestwith two end-to-end paths:mvn -Prun-its -Dits.test=MavenITmng5359PluginManagementExecutionTest verifysuccessfully across all 90 reactor modules.initializeexecutes no clean goal, explicit-plugininitializeexecutescustom-clean, and the normalcleanlifecycle still executes onlydefault-clean.Compatibility and scope
This change is limited to lifecycle-binding injection and does not modify public APIs. Explicit
build/pluginsdeclarations continue to receive normal plugin-management merging. The implementation replaces the previous clone/full-merge path with one clone or builder plus two field merges.Following this checklist to help us incorporate the contribution quickly and easily:
This pull request addresses one issue without unrelated changes.
The description explains what the pull request does, how, and why.
The commit has a meaningful subject line and body.
Behavioral unit tests fail without the runtime change.
mvn verifypassed as part of the broadermvn -Prun-its verifyrun.The targeted Core IT passed successfully.
I hereby declare this contribution to be licenced under the Apache License Version 2.0, January 2004
In any other case, I have filed an Apache Individual Contributor License Agreement.