Skip to content

[MNG-5359] Do not activate pluginManagement executions through lifecycle bindings - #13041

Open
goutamadwant wants to merge 2 commits into
apache:masterfrom
goutamadwant:mng-5359/plugin-management-executions
Open

[MNG-5359] Do not activate pluginManagement executions through lifecycle bindings#13041
goutamadwant wants to merge 2 commits into
apache:masterfrom
goutamadwant:mng-5359/plugin-management-executions

Conversation

@goutamadwant

Copy link
Copy Markdown
Contributor

Fixes #6918.

Problem

When lifecycle bindings introduce a plugin that also appears in pluginManagement, Maven currently clones and merges the entire managed plugin. This makes pluginManagement active by copying managed executions into the lifecycle plugin, even though the plugin was never declared in build/plugins.

On current master, a maven-clean-plugin execution named custom-clean, declared only in pluginManagement and bound to initialize, is executed by mvn initialize.

Change

  • Use the lifecycle-supplied plugin as the merge base.
  • Overlay only the managed plugin version and configuration, preserving their existing dominance.
  • Keep lifecycle executions and all other plugin-level fields unchanged.
  • Apply the same behavior to the legacy mutable model and the Maven 4 immutable model.

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

  • Added focused merger tests for both model implementations. They verify:
    • managed version and configuration are applied;
    • managed configuration wins on conflicting keys;
    • lifecycle executions and input locations are preserved;
    • managed executions, dependencies, extensions, and inheritance flags are not copied.
  • Added MavenITmng5359PluginManagementExecutionTest with two end-to-end paths:
    • a management-only execution remains inactive;
    • the same execution activates when the plugin is explicitly declared.
  • Ran mvn -Prun-its -Dits.test=MavenITmng5359PluginManagementExecutionTest verify successfully across all 90 reactor modules.
  • Verified the built CLI directly: management-only initialize executes no clean goal, explicit-plugin initialize executes custom-clean, and the normal clean lifecycle still executes only default-clean.

Compatibility and scope

This change is limited to lifecycle-binding injection and does not modify public APIs. Explicit build/plugins declarations 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 verify passed as part of the broader mvn -Prun-its verify run.

  • 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.

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.
@gnodet

gnodet commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

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.
@goutamadwant

Copy link
Copy Markdown
Contributor Author

Thanks @gnodet. The PR includes MavenITmng5359PluginManagementExecutionTest, which reproduces the pluginManagement-only case and includes an explicit plugin declaration as a positive control.

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 MavenITmng5359PluginManagementExecutionTest and MavenITmng4344ManagedPluginExecutionOrderTest. Let me know. thanks!

@gnodet gnodet left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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:

Suggested change
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.

Comment on lines +170 to +181
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())));
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 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.

Comment on lines 89 to +95
}

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()));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 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()))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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");

Comment on lines +38 to +53
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");
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[MNG-5359] Declared execution in PluginMgmt gets bound to lifecycle (regression)

2 participants