diff --git a/README.md b/README.md index a4a8b3710..576d7fec1 100644 --- a/README.md +++ b/README.md @@ -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? @@ -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 '' +} +``` + +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 diff --git a/build.gradle b/build.gradle index cf84c026f..fba6b66ac 100644 --- a/build.gradle +++ b/build.gradle @@ -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' diff --git a/external-consistent-versions/build.gradle b/external-consistent-versions/build.gradle new file mode 100644 index 000000000..b9e43fc4f --- /dev/null +++ b/external-consistent-versions/build.gradle @@ -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'] +} diff --git a/external-consistent-versions/src/main/java/com/palantir/gradle/versions/ConstraintConsumerSettingsPlugin.java b/external-consistent-versions/src/main/java/com/palantir/gradle/versions/ConstraintConsumerSettingsPlugin.java new file mode 100644 index 000000000..1d1e195a1 --- /dev/null +++ b/external-consistent-versions/src/main/java/com/palantir/gradle/versions/ConstraintConsumerSettingsPlugin.java @@ -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 { + + 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 { + 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 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 extractVirtualPlatforms(List dependencies) { + return dependencies.stream() + .map(VirtualPlatformRule::buildPlatformCoordinate) + .mapMulti(Optional::ifPresent) + .collect(Collectors.toList()); + } + + private static Optional 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 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(); + + default Optional> 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 dependencies() { + return List.of(); + } + } + + @Value.Immutable + @JsonDeserialize(as = ImmutableDependency.class) + @JsonIgnoreProperties(ignoreUnknown = true) + interface Dependency { + @JacksonXmlProperty(localName = "groupId") + Optional group(); + + @JacksonXmlProperty(localName = "artifactId") + Optional module(); + + @JacksonXmlProperty(localName = "version") + Optional version(); + } +} diff --git a/external-consistent-versions/src/main/java/com/palantir/gradle/versions/ConstraintProducerPlugin.java b/external-consistent-versions/src/main/java/com/palantir/gradle/versions/ConstraintProducerPlugin.java new file mode 100644 index 000000000..7f3447da9 --- /dev/null +++ b/external-consistent-versions/src/main/java/com/palantir/gradle/versions/ConstraintProducerPlugin.java @@ -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 { + + 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 -> { + 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 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 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 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; + } +} diff --git a/external-consistent-versions/src/test/groovy/com/palantir/gradle/versions/ConstraintConsumerSettingsPluginIntegrationSpec.groovy b/external-consistent-versions/src/test/groovy/com/palantir/gradle/versions/ConstraintConsumerSettingsPluginIntegrationSpec.groovy new file mode 100644 index 000000000..55afc1cc6 --- /dev/null +++ b/external-consistent-versions/src/test/groovy/com/palantir/gradle/versions/ConstraintConsumerSettingsPluginIntegrationSpec.groovy @@ -0,0 +1,312 @@ +/* + * (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.palantir.gradle.plugintesting.ConfigurationCacheSpec +import groovy.transform.CompileStatic +import nebula.test.dependencies.DependencyGraph +import nebula.test.dependencies.GradleDependencyGenerator +import org.gradle.testkit.runner.TaskOutcome +import spock.lang.Unroll + +import static com.palantir.gradle.versions.GradleTestVersions.GRADLE_VERSIONS + +@Unroll +class ConstraintConsumerSettingsPluginIntegrationSpec extends ConfigurationCacheSpec { + + File repo + + @CompileStatic + protected File generateMavenRepo(String... graph) { + DependencyGraph dependencyGraph = new DependencyGraph(graph) + GradleDependencyGenerator generator = new GradleDependencyGenerator( + dependencyGraph, new File(projectDir, "build/testrepogen").toString()) + return generator.generateTestMavenRepo() + } + + void setup() { + repo = generateMavenRepo( + "com.external:some-library:1.0.0 -> com.palantir:service-a:1.0.0", + "com.external:some-other-library:1.0.0 -> com.palantir:service-c:2.0.0", + ) + + publishVersionToRepo('1.0.0') + publishVersionToRepo('2.0.0') + + //language=gradle + settingsFile.text = """ + plugins { id 'com.palantir.externally-consistent-versions' } + """.stripIndent(true) + } + + // ======================================== + // Buildscript classpath alignment tests + // ======================================== + + def '#gradleVersionNumber: buildscript classpath aligns upward when transitive dependency requests lower version'() { + given: + buildFileWithDeps('buildscript', [ + 'com.palantir:service-b:2.0.0', + 'com.external:some-library:1.0.0' // pulls in service-a:1.0.0 + ]) + + expect: + assertAlignedTo('buildscript', '2.0.0') + + where: + gradleVersionNumber << GRADLE_VERSIONS + } + + def '#gradleVersionNumber: buildscript classpath aligns when two transitives'() { + given: + buildFileWithDeps('buildscript', [ + 'com.external:some-other-library:1.0.0', // pulls in service-c:2.0.0 + 'com.external:some-library:1.0.0' // pulls in service-a:1.0.0 + ]) + + expect: + assertAlignedTo('buildscript', '2.0.0') + + where: + gradleVersionNumber << GRADLE_VERSIONS + } + + def '#gradleVersionNumber: buildscript classpath aligns upward when transitive dependency requests higher version'() { + given: + buildFileWithDeps('buildscript', [ + 'com.palantir:service-b:1.0.0', + 'com.external:some-other-library:1.0.0' // pulls in service-c:2.0.0 + ]) + + expect: + assertAlignedTo('buildscript', '2.0.0') + + where: + gradleVersionNumber << GRADLE_VERSIONS + } + + def '#gradleVersionNumber: buildscript classpath aligns downward when force constraint lowers version'() { + given: + buildFileWithDeps('buildscript', [ + 'com.palantir:service-a:2.0.0', + 'com.palantir:service-b:2.0.0' + ], [forceVersion: 'com.palantir:service-b:1.0.0']) + + expect: + assertAlignedTo('buildscript', '1.0.0') + + where: + gradleVersionNumber << GRADLE_VERSIONS + } + + // ======================================== + // Runtime classpath alignment tests + // ======================================== + + def '#gradleVersionNumber: runtime classpath aligns upward when transitive dependency requests lower version'() { + given: + buildFileWithDeps('runtime', [ + 'com.palantir:service-b:2.0.0', + 'com.external:some-library:1.0.0' // pulls in service-a:1.0.0 + ]) + + expect: + assertAlignedTo('runtime', '2.0.0') + + where: + gradleVersionNumber << GRADLE_VERSIONS + } + + def '#gradleVersionNumber: runtime classpath aligns when two transitives'() { + given: + buildFileWithDeps('runtime', [ + 'com.external:some-other-library:1.0.0', // pulls in service-c:2.0.0 + 'com.external:some-library:1.0.0' // pulls in service-a:1.0.0 + ]) + + expect: + assertAlignedTo('runtime', '2.0.0') + + where: + gradleVersionNumber << GRADLE_VERSIONS + } + + def '#gradleVersionNumber: runtime classpath aligns upward when transitive dependency requests higher version'() { + given: + buildFileWithDeps('runtime', [ + 'com.palantir:service-b:1.0.0', + 'com.external:some-other-library:1.0.0' // pulls in service-c:2.0.0 + ]) + + expect: + assertAlignedTo('runtime', '2.0.0') + + where: + gradleVersionNumber << GRADLE_VERSIONS + } + + def '#gradleVersionNumber: runtime classpath aligns downward when force constraint lowers version'() { + given: + buildFileWithDeps('runtime', [ + 'com.palantir:service-a:2.0.0', + 'com.palantir:service-b:2.0.0' + ], [forceVersion: 'com.palantir:service-b:1.0.0']) + + expect: + assertAlignedTo('runtime', '1.0.0') + + where: + gradleVersionNumber << GRADLE_VERSIONS + } + + // ======================================== + // Error handling tests + // ======================================== + + def '#gradleVersionNumber: handles malformed POM metadata gracefully'() { + given: + createMalformedPomMetadata() + buildFileWithDeps('runtime', [ + 'com.palantir:service-a:1.0.0', + 'com.malformed:bad-pom:1.0.0' + ]) + + expect: + runTasks('dependencies').task(':dependencies').outcome == TaskOutcome.SUCCESS + + where: + gradleVersionNumber << GRADLE_VERSIONS + } + + // ======================================== + // Helper methods + // ======================================== + + private void buildFileWithDeps(String scope, List deps, Map options = [:]) { + def forceVersion = options.forceVersion + + if (scope == 'buildscript') { + //language=gradle + buildFile.text = """ + buildscript { + repositories { maven { url "file:///${repo.absolutePath}" } } + dependencies { + ${deps.collect { "classpath '$it'" }.join('\n ')} + } + ${forceVersion ? "configurations.classpath { resolutionStrategy { force '$forceVersion' } }" : ''} + } + plugins { id 'java' } + """.stripIndent(true) + } else { + //language=gradle + buildFile.text = """ + plugins { id 'java' } + repositories { maven { url "file:///${repo.absolutePath}" } } + dependencies { + ${deps.collect { "implementation '$it'" }.join('\n ')} + } + ${forceVersion ? "configurations.runtimeClasspath { resolutionStrategy { force '$forceVersion' } }" : ''} + """.stripIndent(true) + } + } + + private void assertAlignedTo(String scope, String expectedVersion) { + def taskName = "check${scope.capitalize()}Alignment" + def configName = scope == 'buildscript' ? 'buildscript.configurations.classpath' : 'configurations.runtimeClasspath' + + //language=gradle + buildFile << """ + import org.gradle.api.artifacts.component.ModuleComponentIdentifier + + tasks.register('$taskName') { + def artifactsProvider = provider { + ${configName}.incoming.artifactView { + lenient(true) + }.artifacts + } + + doLast { + def versions = artifactsProvider.get() + .findAll { + it.id.componentIdentifier instanceof ModuleComponentIdentifier && + it.id.componentIdentifier.group == 'com.palantir' + } + .collect { it.id.componentIdentifier.version } + .unique() + + assert versions.size() == 1 : "Expected alignment, got: \${versions}" + assert versions[0] == '$expectedVersion' : "Expected $expectedVersion, got \${versions[0]}" + println "SUCCESS: Aligned to $expectedVersion" + } + } + """.stripIndent(true) + + def result = runTasksWithConfigurationCache(taskName) + assert result.task(":$taskName").outcome == TaskOutcome.SUCCESS + assert result.output.contains("SUCCESS: Aligned to $expectedVersion") + } + + private void createMalformedPomMetadata() { + def malformedDir = new File(repo, 'com/malformed/bad-pom/1.0.0') + malformedDir.mkdirs() + new File(malformedDir, 'bad-pom-1.0.0.jar').text = 'fake jar' + new File(malformedDir, 'bad-pom-1.0.0.pom').text = """ + + 4.0.0 + com.malformed + bad-pom + 1.0.0 + + + + + consistent-versions.external-virtual-platform.com.palantir + + + + + + """.stripIndent() + } + + private void publishVersionToRepo(String version) { + ['service-a', 'service-b', 'service-c'].each { serviceName -> + def serviceDir = new File(repo, "com/palantir/${serviceName}/${version}") + serviceDir.mkdirs() + + new File(serviceDir, "${serviceName}-${version}.jar").text = 'fake jar content' + + new File(serviceDir, "${serviceName}-${version}.pom").text = """ + + 4.0.0 + com.palantir + ${serviceName} + ${version} + + + + consistent-versions.external-virtual-platform.com.palantir + _ + ${version} + + + + + """.stripIndent(true) + } + } +} diff --git a/external-consistent-versions/src/test/groovy/com/palantir/gradle/versions/ConstraintProducerPluginIntegrationSpec.groovy b/external-consistent-versions/src/test/groovy/com/palantir/gradle/versions/ConstraintProducerPluginIntegrationSpec.groovy new file mode 100644 index 000000000..59d4e58db --- /dev/null +++ b/external-consistent-versions/src/test/groovy/com/palantir/gradle/versions/ConstraintProducerPluginIntegrationSpec.groovy @@ -0,0 +1,107 @@ +/* + * (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.fasterxml.jackson.databind.ObjectMapper +import com.palantir.gradle.plugintesting.ConfigurationCacheSpec; +import spock.lang.Unroll; + +import static com.palantir.gradle.versions.GradleTestVersions.GRADLE_VERSIONS + + +@Unroll +class ConstraintProducerPluginIntegrationSpec extends ConfigurationCacheSpec { + + void setup() { + //language=gradle + buildFile << """ + import com.palantir.gradle.versions.ConstraintProducerPlugin + + apply plugin: ConstraintProducerPlugin + + allprojects { + group = 'com.palantir' + version = '1.0.0' + } + + subprojects { + apply plugin: 'java' + apply plugin: 'maven-publish' + + publishing { + publications { + maven(MavenPublication) { + from components.java + } + } + } + } + """.stripIndent(true) + + // Create service subprojects + addSubproject('service-a', '// Service A') + addSubproject('service-b', '// Service B') + } + + def "check does not break configuration cache"() { + expect: + runTasksWithConfigurationCache('build') + } + + def "#gradleVersionNumber: published constraints with platform constraint"() { + when: + runTasks('generatePomFileForMavenPublication', 'generateMetadataFileForMavenPublication') + + def virtualPlatformConstraint = new MetadataFile.Dependency( + group: 'consistent-versions.external-virtual-platform.com.palantir', + module: '_', + version: [requires: '1.0.0']) + + then: "service-a's metadata file has the virtual platform constraint" + def serviceAMetadataFilename = new File(projectDir, "service-a/build/publications/maven/module.json") + def serviceAMetadata = new ObjectMapper().readValue(serviceAMetadataFilename, MetadataFile) + + serviceAMetadata.variants == [ + new MetadataFile.Variant( + name: 'runtimeElements', + dependencies: null, + dependencyConstraints: [virtualPlatformConstraint] as Set), + new MetadataFile.Variant( + name: 'apiElements', + dependencies: null, + dependencyConstraints: [virtualPlatformConstraint] as Set) + ] as Set + + and: "service-b's metadata file has the virtual platform constraint" + def serviceBMetadataFilename = new File(projectDir, "service-b/build/publications/maven/module.json") + def serviceBMetadata = new ObjectMapper().readValue(serviceBMetadataFilename, MetadataFile) + + serviceBMetadata.variants == [ + new MetadataFile.Variant( + name: 'runtimeElements', + dependencies: null, + dependencyConstraints: [virtualPlatformConstraint] as Set), + new MetadataFile.Variant( + name: 'apiElements', + dependencies: null, + dependencyConstraints: [virtualPlatformConstraint] as Set), + ] as Set + + where: + gradleVersionNumber << GRADLE_VERSIONS + } +} diff --git a/external-consistent-versions/src/test/groovy/com/palantir/gradle/versions/GradleTestVersions.java b/external-consistent-versions/src/test/groovy/com/palantir/gradle/versions/GradleTestVersions.java new file mode 100644 index 000000000..bb25791ec --- /dev/null +++ b/external-consistent-versions/src/test/groovy/com/palantir/gradle/versions/GradleTestVersions.java @@ -0,0 +1,25 @@ +/* + * (c) Copyright 2019 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 java.util.List; + +public final class GradleTestVersions { + private GradleTestVersions() {} + + public static final List GRADLE_VERSIONS = List.of("8.14.1", "7.6.4"); +} diff --git a/external-consistent-versions/src/test/groovy/com/palantir/gradle/versions/MetadataFile.groovy b/external-consistent-versions/src/test/groovy/com/palantir/gradle/versions/MetadataFile.groovy new file mode 100644 index 000000000..a1aad629f --- /dev/null +++ b/external-consistent-versions/src/test/groovy/com/palantir/gradle/versions/MetadataFile.groovy @@ -0,0 +1,51 @@ +/* + * (c) Copyright 2019 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.fasterxml.jackson.annotation.JsonIgnoreProperties +import groovy.transform.EqualsAndHashCode +import groovy.transform.ToString + +/** + * A class that can parse out a small subset of a + * Gradle Metadata File. + */ +@JsonIgnoreProperties(ignoreUnknown = true) +@ToString(includePackage = false) +@EqualsAndHashCode +class MetadataFile { + Set variants + + @JsonIgnoreProperties(ignoreUnknown = true) + @ToString(includePackage = false) + @EqualsAndHashCode + static class Variant { + String name + Set dependencies + Set dependencyConstraints + } + + @ToString(includePackage = false) + @EqualsAndHashCode + @JsonIgnoreProperties(ignoreUnknown = true) + static class Dependency { + String group + String module + Map version // rich constraints + } +} + diff --git a/settings.gradle b/settings.gradle index 36c02ea7f..67cfce134 100644 --- a/settings.gradle +++ b/settings.gradle @@ -9,3 +9,5 @@ buildscript { } apply plugin: 'com.palantir.jdks.settings' rootProject.name = 'gradle-consistent-versions' + +include 'external-consistent-versions' diff --git a/src/main/java/com/palantir/gradle/versions/VersionsLockPlugin.java b/src/main/java/com/palantir/gradle/versions/VersionsLockPlugin.java index 6b94dbdf9..02e3c2426 100644 --- a/src/main/java/com/palantir/gradle/versions/VersionsLockPlugin.java +++ b/src/main/java/com/palantir/gradle/versions/VersionsLockPlugin.java @@ -1,5 +1,5 @@ /* - * (c) Copyright 2019 Palantir Technologies Inc. All rights reserved. + * (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. diff --git a/versions.lock b/versions.lock index 2c652b6f8..061b6ee41 100644 --- a/versions.lock +++ b/versions.lock @@ -2,14 +2,16 @@ com.fasterxml.jackson.core:jackson-annotations:2.19.2 (3 constraints: a5408176) -com.fasterxml.jackson.core:jackson-core:2.19.2 (3 constraints: a5408176) +com.fasterxml.jackson.core:jackson-core:2.19.2 (4 constraints: a856fb0e) -com.fasterxml.jackson.core:jackson-databind:2.19.2 (3 constraints: 57331be9) +com.fasterxml.jackson.core:jackson-databind:2.19.2 (4 constraints: 5a498448) com.fasterxml.jackson.dataformat:jackson-dataformat-xml:2.19.2 (1 constraints: 40054c3b) com.fasterxml.jackson.datatype:jackson-datatype-guava:2.19.2 (1 constraints: 40054c3b) +com.fasterxml.jackson.datatype:jackson-datatype-jdk8:2.19.2 (1 constraints: 40054c3b) + com.fasterxml.woodstox:woodstox-core:7.1.1 (1 constraints: 3d174926) com.google.errorprone:error_prone_annotations:2.41.0 (2 constraints: 7e0f1fa1)