Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
51 commits
Select commit Hold shift + click to select a range
427f958
nearly working
FinlayRJW Oct 1, 2025
4a69fd3
working but I don't like it
FinlayRJW Oct 1, 2025
c4f3931
working for buildScript
FinlayRJW Oct 1, 2025
d4039d8
tidy tests
FinlayRJW Oct 2, 2025
86711c1
tidy tests
FinlayRJW Oct 2, 2025
3a4e8de
not working
FinlayRJW Oct 2, 2025
15cb035
fix so can debug
FinlayRJW Oct 3, 2025
f8b6eca
check GMM directly
FinlayRJW Oct 3, 2025
9122a6a
read GMM directly
FinlayRJW Oct 3, 2025
32a4b6c
tidy up
FinlayRJW Oct 3, 2025
e95fad7
tidy up
FinlayRJW Oct 3, 2025
e053db8
tidy up
FinlayRJW Oct 6, 2025
801d0ac
make tests cleaner
FinlayRJW Oct 7, 2025
5ae4125
tidy tests
FinlayRJW Oct 7, 2025
3a4fa16
tidy up
FinlayRJW Oct 7, 2025
a608475
add virtual platform
FinlayRJW Oct 7, 2025
cb7d2ec
tidy up
FinlayRJW Oct 7, 2025
1ca7286
tidy up
FinlayRJW Oct 7, 2025
f827d2c
spotless
FinlayRJW Oct 7, 2025
0ab6ccc
Update src/main/java/com/palantir/gradle/versions/VirtualPlatformSett…
FinlayRJW Oct 7, 2025
896f366
Update src/main/java/com/palantir/gradle/versions/AddVirtualPlatformP…
FinlayRJW Oct 7, 2025
3851352
fix throw
FinlayRJW Oct 7, 2025
baeac65
only care about POM
FinlayRJW Oct 7, 2025
232402e
properly defined input factory
FinlayRJW Oct 7, 2025
01f7c5e
tidy up
FinlayRJW Oct 7, 2025
5b9dd60
two transitives
FinlayRJW Oct 7, 2025
de77ba0
apply from settings plugn
FinlayRJW Oct 8, 2025
16210df
remove gradle 6 + CC
FinlayRJW Oct 8, 2025
4e632e1
no need for gradle property
FinlayRJW Oct 8, 2025
c1ffec2
parse pom
FinlayRJW Oct 8, 2025
dc9b80c
explode
FinlayRJW Oct 8, 2025
9990c29
explode
FinlayRJW Oct 8, 2025
543e76b
rename
FinlayRJW Oct 8, 2025
8aaed15
Update src/main/java/com/palantir/gradle/versions/ExternallyConsisten…
FinlayRJW Oct 8, 2025
e1d9d97
actually collect all the vps
FinlayRJW Oct 8, 2025
15f27e7
before project
FinlayRJW Oct 8, 2025
6500038
add CC test
FinlayRJW Oct 8, 2025
b94709e
create consistent-versions project
FinlayRJW Oct 8, 2025
507778b
move to settings project
FinlayRJW Oct 8, 2025
c3d54b1
make private
FinlayRJW Oct 8, 2025
417c3ac
make private
FinlayRJW Oct 8, 2025
cb34c21
tidy up
FinlayRJW Oct 8, 2025
dbc3948
this is fine
FinlayRJW Oct 8, 2025
0cdf53d
tidy up
FinlayRJW Oct 8, 2025
f61f3cc
rename classes
FinlayRJW Oct 8, 2025
3b16fe2
update readme
FinlayRJW Oct 8, 2025
7568021
improve readme
FinlayRJW Oct 8, 2025
0552c0b
cacheable rule!
FinlayRJW Oct 9, 2025
df73149
filter to only java
FinlayRJW Oct 9, 2025
aa7c874
OBO error
FinlayRJW Oct 9, 2025
73e729d
Merge remote-tracking branch 'origin/develop' into finlayw/VP
FinlayRJW Oct 9, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,9 @@ Direct dependencies are specified in a top level `versions.props` file and then
1. [Technical explanation](#technical-explanation)
1. Are these vanilla Gradle lockfiles?
1. Conflict-safe lock files
1. [External Version Alignment](#external-version-alignment)
1. Usage
1. Why Not Use BOMs?



Expand Down Expand Up @@ -528,3 +531,33 @@ Internally, a few projects started using Nebula's [Gradle Dependency Lock Plugin
- Contributors often updated versions.props but forgot to update the lockfiles, leading them to think they'd picked up a new version when they actually hadn't!

Both of these problems are solved by this plugin because the new gradle lock files are extremely compact (one line per dependency) and they can never get out of date because gradle validates their correctness every time you resolve the unifiedClasspath configuration.

## External Version Alignment

When publishing libraries for external consumption, you may want to ensure that consumers always resolve all modules from your group (e.g., `com.palantir.foo:*`) to the same version—even when transitive dependencies or explicit downgrades would otherwise cause mismatches. This provides the same alignment guarantees as `*` wildcards in `versions.props`, but for the buildScript as well.

### Usage

Both library publishers and consumers apply the same settings plugin:

```gradle
// settings.gradle
plugins {
id 'com.palantir.externally-consistent-versions' version '<current version>'
}
```

When you publish your libraries, the plugin automatically embeds alignment metadata. Consumers who also apply the plugin will automatically enforce that all modules from the same group resolve to the same version.

### Why Not Use BOMs?

The main drive away from BOMs or inter-project constraints is to enable version alignment even on downgrade via `force` or `strictly` constraints.

Traditional BOMs have some further limitations:
- **Require a separate published project** to host the BOM
- **Must be explicitly imported** by every consumer

This plugin:
- **Works automatically** via published metadata—no separate BOM artifact needed
- **Enforces strict alignment** even when versions are downgraded via `force` or `strictly` constraints
- **No explicit imports required**—alignment happens transparently for any consumer using the plugin
3 changes: 2 additions & 1 deletion build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,12 @@ buildscript {
classpath 'com.palantir.gradle.gitversion:gradle-git-version:4.0.0'
classpath 'com.palantir.gradle.guide:gradle-guide:1.13.0'
classpath 'com.palantir.gradle.idea-configuration:gradle-idea-configuration:0.5.0'
classpath 'com.palantir.gradle.shadow-jar:gradle-shadow-jar:3.1.0'
}
}

plugins {
id 'com.gradle.plugin-publish' version '2.0.0'
id 'com.gradle.plugin-publish' version '2.0.0'
}

apply plugin: 'com.palantir.external-publish'
Expand Down
40 changes: 40 additions & 0 deletions external-consistent-versions/build.gradle
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
apply plugin: 'com.palantir.external-publish-jar'
apply plugin: 'java-gradle-plugin'
apply plugin: 'com.palantir.gradle-plugin-testing'
apply plugin: 'groovy'
apply plugin: 'com.palantir.shadow-jar'

dependencies {
shadeTransitively 'com.fasterxml.jackson.dataformat:jackson-dataformat-xml'
shadeTransitively 'com.fasterxml.jackson.datatype:jackson-datatype-guava'
shadeTransitively 'com.fasterxml.jackson.datatype:jackson-datatype-jdk8'

testImplementation 'com.netflix.nebula:nebula-test'

annotationProcessor "org.immutables:value"
compileOnly "org.immutables:value::annotations"
}

gradlePlugin {
website = 'https://github.com/palantir/gradle-consistent-versions'
vcsUrl = 'https://github.com/palantir/gradle-consistent-versions'

plugins {
externallyConsistentVersions {
id = 'com.palantir.externally-consistent-versions'
implementationClass = 'com.palantir.gradle.versions.ConstraintConsumerSettingsPlugin'
displayName = 'Settings plugin to automatically assign dependencies to a virtual platforms'
description = displayName
tags.set(['versions'])
}
}
}

test {
jvmArgs '--add-opens', 'java.base/java.lang.invoke=ALL-UNNAMED'
jvmArgs '--add-opens', 'java.base/java.util=ALL-UNNAMED'
}

gradleTestUtils {
gradleVersions = ['8.14.3']
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
/*
* (c) Copyright 2025 Palantir Technologies Inc. All rights reserved.
*
* Licensed 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 com.palantir.gradle.versions;

import com.ctc.wstx.stax.WstxInputFactory;
import com.ctc.wstx.stax.WstxOutputFactory;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import com.fasterxml.jackson.dataformat.xml.XmlMapper;
import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlElementWrapper;
import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty;
import com.fasterxml.jackson.datatype.jdk8.Jdk8Module;
import java.io.IOException;
import java.io.InputStream;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
import javax.inject.Inject;
import org.gradle.api.Plugin;
import org.gradle.api.UncheckedIOException;
import org.gradle.api.artifacts.CacheableRule;
import org.gradle.api.artifacts.ComponentMetadataContext;
import org.gradle.api.artifacts.ComponentMetadataRule;
import org.gradle.api.artifacts.ModuleVersionIdentifier;
import org.gradle.api.artifacts.repositories.RepositoryResourceAccessor;
import org.gradle.api.initialization.Settings;
import org.gradle.api.logging.Logger;
import org.gradle.api.logging.Logging;
import org.gradle.api.plugins.JavaPlugin;
import org.immutables.value.Value;

public class ConstraintConsumerSettingsPlugin implements Plugin<Settings> {

private static final Logger log = Logging.getLogger(ConstraintConsumerSettingsPlugin.class);

@Override
public final void apply(Settings settings) {
settings.getGradle().rootProject(rootProject -> {
rootProject.getPluginManager().apply(ConstraintProducerPlugin.class);
});

settings.getGradle().beforeProject(project -> {
project.getDependencies().getComponents().all(VirtualPlatformRule.class);
project.getBuildscript().getDependencies().getComponents().all(VirtualPlatformRule.class);
});
}

@CacheableRule
public abstract static class VirtualPlatformRule implements ComponentMetadataRule {
Copy link
Contributor

Choose a reason for hiding this comment

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

We should test the performance of this on a large internal repo wrt to dependency resolution times. There is the option to cache these rules, we should see if we need to.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Testing on an internal project seems to indicate that on CI no more network requests are made when resolving the runtimeClasspath and compileClasspath see discussion here https://pl.ntr/2xp

private static final ObjectMapper XML_MAPPER =
new XmlMapper(new WstxInputFactory(), new WstxOutputFactory()).registerModule(new Jdk8Module());

@Inject
protected abstract RepositoryResourceAccessor getRepositoryResourceAccessor();

@Override
public final void execute(ComponentMetadataContext context) {
ModuleVersionIdentifier id = context.getDetails().getId();
processVariant(context, id, JavaPlugin.RUNTIME_ELEMENTS_CONFIGURATION_NAME);
processVariant(context, id, JavaPlugin.API_ELEMENTS_CONFIGURATION_NAME);
}

private void processVariant(ComponentMetadataContext context, ModuleVersionIdentifier id, String variantName) {
context.getDetails().withVariant(variantName, _variant -> {
String pomPath = buildPomPath(id);
getRepositoryResourceAccessor().withResource(pomPath, resource -> {
parsePom(resource)
.flatMap(Metadata::extractDependencies)
.map(VirtualPlatformRule::extractVirtualPlatforms)
.filter(platforms -> !platforms.isEmpty())
.ifPresent(platforms -> assignToPlatforms(context, id, platforms));
});
});
}

private static Optional<Metadata> parsePom(InputStream resource) {
try {
return Optional.of(XML_MAPPER.readValue(resource, Metadata.class));
} catch (IOException e) {
throw new UncheckedIOException("Failed to parse POM: ", e);
}
}

private static List<String> extractVirtualPlatforms(List<Dependency> dependencies) {
return dependencies.stream()
.map(VirtualPlatformRule::buildPlatformCoordinate)
.<String>mapMulti(Optional::ifPresent)
.collect(Collectors.toList());
}

private static Optional<String> buildPlatformCoordinate(Dependency dependency) {
return dependency
.group()
.filter(group -> group.startsWith(ConstraintProducerPlugin.VIRTUAL_PLATFORM_PREFIX))
.map(group -> group.substring(ConstraintProducerPlugin.VIRTUAL_PLATFORM_PREFIX.length() + 1))
.flatMap(extractedGroup -> dependency.module().map(module -> extractedGroup + ":" + module));
}

private static String buildPomPath(ModuleVersionIdentifier id) {
String groupPath = id.getGroup().replace('.', '/');
return String.format(
"%s/%s/%s/%s-%s.pom", groupPath, id.getName(), id.getVersion(), id.getName(), id.getVersion());
}

private static void assignToPlatforms(
ComponentMetadataContext context, ModuleVersionIdentifier id, List<String> platformCoordinates) {
platformCoordinates.forEach(platform -> {
log.debug("Assigning component {} to virtual platform {}", id, platform);
context.getDetails().belongsTo(platform + ":" + id.getVersion(), true);
});
}
}

@Value.Immutable
@JsonDeserialize(as = ImmutableMetadata.class)
@JsonIgnoreProperties(ignoreUnknown = true)
interface Metadata {
Optional<DependencyManagement> dependencyManagement();

default Optional<List<Dependency>> extractDependencies() {
return dependencyManagement().map(DependencyManagement::dependencies);
}
}

@Value.Immutable
@JsonDeserialize(as = ImmutableDependencyManagement.class)
@JsonIgnoreProperties(ignoreUnknown = true)
interface DependencyManagement {
@JacksonXmlElementWrapper(localName = "dependencies")
@JacksonXmlProperty(localName = "dependency")
@Value.Default
default List<Dependency> dependencies() {
return List.of();
}
}

@Value.Immutable
@JsonDeserialize(as = ImmutableDependency.class)
@JsonIgnoreProperties(ignoreUnknown = true)
interface Dependency {
@JacksonXmlProperty(localName = "groupId")
Optional<String> group();

@JacksonXmlProperty(localName = "artifactId")
Optional<String> module();

@JacksonXmlProperty(localName = "version")
Optional<String> version();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
/*
* (c) Copyright 2025 Palantir Technologies Inc. All rights reserved.
*
* Licensed 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 com.palantir.gradle.versions;

import com.google.common.collect.ImmutableList;
import java.util.Set;
import java.util.stream.Collectors;
import org.gradle.api.Named;
import org.gradle.api.NamedDomainObjectProvider;
import org.gradle.api.Plugin;
import org.gradle.api.Project;
import org.gradle.api.artifacts.Configuration;
import org.gradle.api.plugins.JavaPlugin;
import org.gradle.api.publish.Publication;
import org.gradle.api.publish.PublishingExtension;
import org.gradle.api.publish.ivy.IvyPublication;
import org.gradle.api.publish.maven.MavenPublication;

public class ConstraintProducerPlugin implements Plugin<Project> {

static final String VIRTUAL_PLATFORM_PREFIX = "consistent-versions.external-virtual-platform";

@Override
public final void apply(Project rootProject) {
if (!rootProject.equals(rootProject.getRootProject())) {
throw new IllegalStateException("ConstraintProducerPlugin must be applied to the root project");
}

rootProject.afterEvaluate(project -> {
Copy link
Contributor

Choose a reason for hiding this comment

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

is this still configuration cacheable ?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I think so - I've added a basic test

    def "check does not break configuration cache"() {
        expect:
        runTasksWithConfigurationCache('--write-locks')
        runTasksWithConfigurationCache('build')
    }

Which I think would blow up if there where any configuration cache issues

project.getAllprojects().stream()
.filter(ConstraintProducerPlugin::isJavaLibrary)
.collect(Collectors.groupingBy(proj -> String.valueOf(proj.getGroup()), Collectors.toSet()))
.forEach((groupName, projects) -> {
if (projects.size() > 1) {
applyVirtualPlatformPerGroup(groupName, projects);
}
});
});
}

private void applyVirtualPlatformPerGroup(String groupName, Set<Project> projectGroup) {
String platformCoordinates = VIRTUAL_PLATFORM_PREFIX + "." + groupName + ":_";

projectGroup.stream()
.filter(project -> project.getPluginManager().hasPlugin("java"))
.forEach(project -> addVirtualPlatformToProject(project, platformCoordinates));
}

private void addVirtualPlatformToProject(Project project, String platformCoordinates) {
NamedDomainObjectProvider<Configuration> constraints = project.getConfigurations()
.register("virtualPlatformConstraints", config -> {
config.setDescription(
"Enforces version alignment via virtual platform for modules within the same group");
config.setCanBeResolved(false);
config.setCanBeConsumed(false);
config.setVisible(false);

config.getDependencyConstraints()
.add(project.getDependencies().getConstraints().create(platformCoordinates, constraint -> {
constraint.version(
v -> v.require(project.getVersion().toString()));
constraint.because("Virtual platform for version alignment across group when using "
+ "com.palantir.externally-consistent-versions");
}));
});

project.getConfigurations()
.named(JavaPlugin.API_ELEMENTS_CONFIGURATION_NAME)
.configure(conf -> conf.extendsFrom(constraints.get()));

project.getConfigurations()
.named(JavaPlugin.RUNTIME_ELEMENTS_CONFIGURATION_NAME)
.configure(conf -> conf.extendsFrom(constraints.get()));
}

static boolean isJavaLibrary(Project project) {
if (project.getPluginManager().hasPlugin("nebula.maven-publish")) {
// 'nebula.maven-publish' creates publications lazily which causes inconsistencies based
// on ordering.
return true;
}
PublishingExtension publishing = project.getExtensions().findByType(PublishingExtension.class);
if (publishing == null) {
return false;
}
ImmutableList<String> jarPublications = publishing.getPublications().stream()
.filter(ConstraintProducerPlugin::isLibraryPublication)
.map(Named::getName)
.collect(ImmutableList.toImmutableList());
return !jarPublications.isEmpty();
}

private static boolean isLibraryPublication(Publication publication) {
if (publication instanceof MavenPublication mavenPublication) {
return mavenPublication.getArtifacts().stream().anyMatch(artifact -> "jar".equals(artifact.getExtension()));
}
if (publication instanceof IvyPublication ivyPublication) {
return ivyPublication.getArtifacts().stream().anyMatch(artifact -> "jar".equals(artifact.getExtension()));
}
return true;
}
}
Loading