Skip to content

Externalize default lifecycle plugin versions to POM properties - #13080

Open
gnodet wants to merge 2 commits into
apache:masterfrom
gnodet:feature/externalize-plugin-versions
Open

Externalize default lifecycle plugin versions to POM properties#13080
gnodet wants to merge 2 commits into
apache:masterfrom
gnodet:feature/externalize-plugin-versions

Conversation

@gnodet

@gnodet gnodet commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Move the 13 hardcoded plugin version constants from Java source into POM properties in impl/maven-core/pom.xml. The values are filtered at build time into plugin-versions.properties and loaded at runtime by a new PluginVersions utility class.

This makes the default lifecycle plugin versions visible to dependency-update bots (Dependabot, Renovate) that scan POM files for version properties, enabling automated version bump PRs.

Changes:

  • impl/maven-core/pom.xml — Add <properties> section with version.maven-<name>-plugin entries for all 13 default plugins
  • plugin-versions.properties — New resource file with ${...} placeholders, filtered at build time
  • PluginVersions.java — New utility class that loads versions from the properties file and exposes them as constants
  • AbstractLifecycleMappingProvider.java — Replace hardcoded version strings with PluginVersions.* constants
  • DefaultLifecycleRegistry.java — Replace hardcoded clean/site plugin versions with PluginVersions.* constants

No behavioral change: all version values are identical to the previous hardcoded constants.

Follow-up to the discussion in #13076 about automating plugin version bumps.

Move the 13 hardcoded plugin version constants from Java source into
POM properties in impl/maven-core/pom.xml. The values are filtered at
build time into plugin-versions.properties and loaded at runtime by a
new PluginVersions utility class.

This makes the default lifecycle plugin versions visible to
dependency-update bots (Dependabot, Renovate) that scan POM files for
version properties, enabling automated version bump PRs.

No behavioral change: all version values are identical to the previous
hardcoded constants.
@gnodet gnodet added this to the 4.0.0-rc-7 milestone Sep 8, 2026
@gnodet
gnodet requested a review from ascheman September 8, 2026 15:52

@gnodet gnodet left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Three issues to fix before merge: a silent-failure mode when resource filtering is not applied, a public API surface that doesn't need to be public, and a milestone mismatch.

This review was generated by an AI agent, Hermès, on behalf of @gnodet.

Comment on lines +59 to +67
public static String version(String pluginArtifactId) {
String key = pluginArtifactId + ".version";
String version = VERSIONS.getProperty(key);
if (version == null) {
throw new IllegalArgumentException("No default version defined for " + pluginArtifactId + "; add " + key
+ " to plugin-versions.properties");
}
return version;
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

⚠️ Silent failure: unfiltered placeholder survives null check

VERSIONS.getProperty(key) returns "${version.maven-clean-plugin}" (not null) when resource filtering is skipped — e.g. when the class is loaded from an IDE or test classpath built without the Maven resources plugin running. The null guard does not catch this: the constant is set to a literal ${…} string, Maven silently tries to resolve a plugin at that version, and the user gets a cryptic "not found in repository" error at build time with no clue that the properties file was never filtered.

Add a format guard that fails fast at class-init time:

Suggested change
public static String version(String pluginArtifactId) {
String key = pluginArtifactId + ".version";
String version = VERSIONS.getProperty(key);
if (version == null) {
throw new IllegalArgumentException("No default version defined for " + pluginArtifactId + "; add " + key
+ " to plugin-versions.properties");
}
return version;
}
public static String version(String pluginArtifactId) {
String key = pluginArtifactId + ".version";
String version = VERSIONS.getProperty(key);
if (version == null) {
throw new IllegalArgumentException("No default version defined for " + pluginArtifactId + "; add " + key
+ " to plugin-versions.properties");
}
if (version.startsWith("${")) {
throw new ExceptionInInitializerError(
"plugin-versions.properties was not filtered at build time; "
+ key + " still contains placeholder: " + version);
}
return version;
}

* @return the version string, never {@code null}
* @throws IllegalArgumentException if the plugin is not listed in the properties file
*/
public static String version(String pluginArtifactId) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

🔸 Unnecessary public API surface

version(String) is public but its only callers are the 13 constants in this same class (all called during class initialisation). Once the constants exist, nothing outside this class needs to call version() at runtime — the public constants are the intended API. Exposing the method invites callers to store the result in their own fields, bypassing future caching or validation improvements.

Make it private:

Suggested change
public static String version(String pluginArtifactId) {
private static String version(String pluginArtifactId) {

If external code genuinely needs to look up an arbitrary plugin version, that can be added as a separate, explicitly-documented public method later (with a stronger contract).

Comment on lines +71 to +84
public static final String CLEAN = version("maven-clean-plugin");
public static final String COMPILER = version("maven-compiler-plugin");
public static final String DEPLOY = version("maven-deploy-plugin");
public static final String EAR = version("maven-ear-plugin");
public static final String EJB = version("maven-ejb-plugin");
public static final String INSTALL = version("maven-install-plugin");
public static final String JAR = version("maven-jar-plugin");
public static final String PLUGIN = version("maven-plugin-plugin");
public static final String RAR = version("maven-rar-plugin");
public static final String RESOURCES = version("maven-resources-plugin");
public static final String SITE = version("maven-site-plugin");
public static final String SUREFIRE = version("maven-surefire-plugin");
public static final String WAR = version("maven-war-plugin");
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

💡 No test for the loading mechanism

The static initialiser, the filtering round-trip, and the null/placeholder guards are the critical path of this new class, yet there is no unit test. A minimal test verifying that every constant is non-null and does not look like an unfiltered placeholder (!CLEAN.startsWith("${")) would catch the filtering-skipped scenario and guard against future regressions (e.g. a new constant added to the class but forgotten in the properties file).

Example:

@Test
void pluginVersionsAreResolved() {
    // Verify all constants are loaded and not unfiltered placeholders
    for (Field f : PluginVersions.class.getFields()) {
        if (f.getType() == String.class) {
            String value = (String) f.get(null);
            assertNotNull(value, f.getName() + " is null");
            assertFalse(value.startsWith("${"), f.getName() + " is unfiltered: " + value);
        }
    }
}

@gnodet

gnodet commented Sep 8, 2026

Copy link
Copy Markdown
Contributor Author

Milestone mismatch: the PR targets master (4.1.0-SNAPSHOT) but is assigned to milestone 4.0.0-rc-7 (which tracks the maven-4.0.x branch). The @since 4.1.0 Javadoc in PluginVersions confirms this is intended for 4.1.0. Please update the milestone to 4.x or 4.1.0.

This comment was generated by an AI agent, Hermès, on behalf of @gnodet.

public abstract class AbstractLifecycleMappingProvider implements Provider<LifecycleMapping> {
// START SNIPPET: versions
protected static final String RESOURCES_PLUGIN_VERSION = "3.3.1";
protected static final String RESOURCES_PLUGIN_VERSION = PluginVersions.RESOURCES;

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.

Maybe deprecate these fields, or just remove them if this is all new in 4.0.lx

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Agreed — since these fields are protected and could be referenced by extensions subclassing AbstractLifecycleMappingProvider, I'll deprecate them for 4.1.0 with @Deprecated(since = "4.1.0", forRemoval = true) rather than removing outright. They already delegate to PluginVersions.* constants, so the deprecation is purely a signal to migrate.

This comment was generated by an AI agent, Hermès, on behalf of @gnodet.

@gnodet gnodet modified the milestones: 4.0.0-rc-7, 4.1.0 Sep 9, 2026

@ascheman ascheman 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 @gnodet — I'm on board with the goal (getting the default lifecycle plugin versions out of hardcoded Java constants into something maintainable and bot-updatable), and the mechanism works with CI green. But I'd like to see a rework before it lands, mainly around duplication and whether it actually delivers the Dependabot goal.

1. The version list is now triplicated. Each plugin version lives in three places that must stay in sync:

  • impl/maven-core/pom.xml<version.maven-clean-plugin>3.4.0</version.maven-clean-plugin>
  • plugin-versions.propertiesmaven-clean-plugin.version=${version.maven-clean-plugin}
  • PluginVersions.javapublic static final String CLEAN = version("maven-clean-plugin");

Previously each version lived in exactly one place (the constant). Now adding or changing a plugin means touching all three, and it's easy to update the POM but forget the properties file or the constant. The POM property (for the bots) and the filtered .properties (to carry the value to runtime) are both necessary — but the 13 hand-written constants are redundant: callers could use PluginVersions.version("maven-clean-plugin") directly (or a small enum keyed by artifactId), giving the plugin list a single source of truth and dropping the most error-prone layer.

2. Will Dependabot actually bump these? The motivation is bot-visibility, but the new version.* properties aren't referenced by any <dependency>/<plugin> in the reactor — only by resource filtering. Dependabot's Maven ecosystem bumps property-driven versions of declared dependencies/plugins; it doesn't track free-standing version.* properties. Have you confirmed Dependabot (or Renovate) picks these up as-is? If not, we'd also need to declare these plugins (e.g. a <pluginManagement> block referencing the properties) so the bot has something to attach the bump to — otherwise this adds indirection without the automation it's meant to provide.

3. No test guards the filtering. If resource filtering ever regresses, plugin-versions.properties would ship literal ${version.maven-clean-plugin} and every default lifecycle binding would break at runtime with an invalid coordinate. A small unit test asserting every key resolves to a non-${ value would catch that.

Nit: key order differs between the POM (version.maven-clean-plugin) and the file (maven-clean-plugin.version) — harmless, but worth aligning.

Happy with the direction — I'd just like the triplication reduced and the Dependabot path confirmed before it goes in.

@gnodet

gnodet commented Sep 10, 2026

Copy link
Copy Markdown
Contributor Author

Thanks @ascheman for the thorough review, and @elharo for the inline suggestion. Let me address each point:

1. Triplication

I hear the concern, but I think it's somewhat overstated. The three "copies" aren't really independent sources of truth — they're a pipeline: POM property → filtered .properties file → Java constant. Each layer serves a distinct purpose:

  • POM property: bot-visible, build-time source of truth
  • .properties file: carries the value into the classpath (bridge between Maven build and Java runtime)
  • Java constant: type-safe, IDE-friendly, refactor-safe access for callers

Dropping the constants in favor of raw version("maven-clean-plugin") calls would trade compile-time safety for stringly-typed lookups — a typo in the artifact ID silently compiles and only blows up at runtime. The constants catch that at the first build.

That said, I agree the error-prone scenario (adding a plugin to the POM but forgetting the .properties or the constant) should be guarded. The unit test suggested in the earlier review (and your point 3) covers exactly that.

2. Dependabot / Renovate effectiveness

This is the strongest point, and you're right to flag it. Free-standing POM properties not referenced by any <dependency> or <plugin> will not be picked up by Dependabot's Maven ecosystem — it only tracks properties used in declared dependency/plugin version elements. Renovate has the same limitation.

Two options to fix this:

  • (a) Add a <pluginManagement> block in the same POM that declares all 13 plugins with ${version.maven-*-plugin} — the bots would then see real plugin declarations referencing the properties and propose bumps. This is the simplest path.
  • (b) Use Renovate's regexManagers or Dependabot's custom ecosystem with regex — fragile and repo-specific.

I'll go with (a) in the next push.

3. Test

Agreed — this was already raised in the initial review. I'll add a unit test that reflectively checks all public static final String fields are non-null and not unfiltered placeholders.

Nit (key naming)

Good catch. I'll align: version.maven-clean-plugin in the POM → version.maven-clean-plugin in the properties file too (dropping the .version suffix / using the same key).

@elharo's suggestion (deprecate/remove fields)

Since AbstractLifecycleMappingProvider is protected API (subclassed by packaging providers, potentially by extensions), the safe path for the master branch (4.1.0) is to deprecate the fields and delegate to PluginVersions.*. I'll add @Deprecated(since = "4.1.0", forRemoval = true) in the next push.


Next push will address: placeholder guard, private version(), unit test, <pluginManagement> for bot visibility, key naming alignment, and field deprecation.

This comment was generated by an AI agent, Hermès, on behalf of @gnodet.

- PluginVersions.version(): make private, add placeholder guard
- plugin-versions.properties: align key naming with POM properties
- AbstractLifecycleMappingProvider: deprecate version fields
- pom.xml: add pluginManagement for bot visibility (Dependabot/Renovate)
- Add PluginVersionsTest: verify all constants are resolved
@gnodet

gnodet commented Sep 10, 2026

Copy link
Copy Markdown
Contributor Author

All review feedback addressed in f0bc8d2:

  1. Placeholder guardversion() now fails fast with ExceptionInInitializerError if a value still looks like ${…} (unfiltered)
  2. Private version() — method is now private; the public constants are the only API
  3. Unit testPluginVersionsTest reflectively asserts all public static final String fields are non-null and not unfiltered placeholders, with a floor check (≥ 13 constants)
  4. Bot visibility — added <pluginManagement> block with all 13 plugins referencing ${version.maven-*-plugin} properties, so Dependabot/Renovate can track them as real plugin declarations
  5. Key naming alignment — properties file keys now use version.maven-<name>-plugin (matching the POM property names) instead of maven-<name>-plugin.version
  6. Field deprecation — all 11 protected static final version fields in AbstractLifecycleMappingProvider annotated with @Deprecated(since = "4.1.0", forRemoval = true), pointing to PluginVersions.* replacements

This comment was generated by an AI agent, Hermès, on behalf of @gnodet.

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.

3 participants