Emit dependency management in published POMs and freeze uncovered versions
Some checks failed
bld-ci / build (./bld, 17, macos-latest, false) (push) Has been cancelled
bld-ci / build (./bld, 21, macos-latest, false) (push) Has been cancelled
bld-ci / build (./bld, 25, macos-latest, false) (push) Has been cancelled
bld-ci / build (./bld, 26, macos-latest, false) (push) Has been cancelled
bld-ci / build (.\bld.bat, 17, windows-latest, false) (push) Has been cancelled
bld-ci / build (.\bld.bat, 21, windows-latest, false) (push) Has been cancelled
bld-ci / build (.\bld.bat, 25, windows-latest, false) (push) Has been cancelled
bld-ci / build (.\bld.bat, 26, windows-latest, false) (push) Has been cancelled
bld-ci / build (unittests, password, unittests, mariadb:10.9, mysql:8, gvenzl/oracle-free:latest, gvenzl/oracle-xe:18-slim, postgres:15, ./bld, 17, ubuntu-latest, true) (push) Has been cancelled
bld-ci / build (unittests, password, unittests, mariadb:10.9, mysql:8, gvenzl/oracle-free:latest, gvenzl/oracle-xe:18-slim, postgres:15, ./bld, 21, ubuntu-latest, true) (push) Has been cancelled
bld-ci / build (unittests, password, unittests, mariadb:10.9, mysql:8, gvenzl/oracle-free:latest, gvenzl/oracle-xe:18-slim, postgres:15, ./bld, 25, ubuntu-latest, true) (push) Has been cancelled
bld-ci / build (unittests, password, unittests, mariadb:10.9, mysql:8, gvenzl/oracle-free:latest, gvenzl/oracle-xe:18-slim, postgres:15, ./bld, 26, ubuntu-latest, true) (push) Has been cancelled
javadocs-pages / deploy (push) Has been cancelled

This commit is contained in:
Geert Bevin 2026-07-14 21:52:48 -04:00
parent 734000e6dd
commit f6aeaa090d
5 changed files with 454 additions and 0 deletions

View file

@ -52,6 +52,7 @@ public class PublishOperation extends AbstractOperation<PublishOperation> {
private ZonedDateTime moment_ = null;
private final List<Repository> repositories_ = new ArrayList<>();
private final List<Repository> dependencyRepositories_ = new ArrayList<>();
private final DependencyScopes dependencies_ = new DependencyScopes();
private PublishInfo info_ = new PublishInfo();
private PublishProperties publishProperties_ = new PublishProperties();
@ -78,6 +79,7 @@ public class PublishOperation extends AbstractOperation<PublishOperation> {
}
executeValidateArtifacts();
executeResolveVersionlessDependencies();
var actual_version = info().version();
@ -103,6 +105,50 @@ public class PublishOperation extends AbstractOperation<PublishOperation> {
}
}
/**
* Part of the {@link #execute} operation, resolves the versions of
* dependencies that were declared without one and that are not covered
* by a BOM in the same scope, so that the published POM is always
* complete.
* <p>
* BOMs that were declared without a version are resolved to their
* latest version too.
*
* @since 2.4.0
*/
protected void executeResolveVersionlessDependencies() {
for (var scope : List.of(Scope.compile, Scope.runtime, Scope.provided)) {
var scoped_dependencies = dependencies().get(scope);
if (scoped_dependencies == null) {
continue;
}
// resolve the versions of BOMs that were declared without one
if (!scoped_dependencies.boms().isEmpty()) {
var base_resolution = new VersionResolution(properties());
var declared_boms = List.copyOf(scoped_dependencies.boms());
scoped_dependencies.boms().clear();
for (var bom : declared_boms) {
if (bom.version().equals(VersionNumber.UNKNOWN)) {
var latest = new DependencyResolver(base_resolution, artifactRetriever(), dependencyRepositories(), bom).latestVersion();
bom = new Bom(bom.groupId(), bom.artifactId(), latest, bom.classifier());
}
scoped_dependencies.boms().add(bom);
}
}
var resolution = new VersionResolution(properties(), artifactRetriever(), dependencyRepositories(), scoped_dependencies.boms());
for (var dependency : List.copyOf(scoped_dependencies)) {
if (dependency.version().equals(VersionNumber.UNKNOWN) &&
!resolution.bomVersions().containsKey(dependency.toArtifactString())) {
var latest = new DependencyResolver(resolution, artifactRetriever(), dependencyRepositories(), dependency).latestVersion();
scoped_dependencies.add(new Dependency(dependency.groupId(), dependency.artifactId(), latest,
dependency.classifier(), dependency.type(), dependency.exclusions(), dependency.parent()));
}
}
}
}
/**
* Part of the {@link #execute} operation, validates the publishing artifacts.
*
@ -625,6 +671,7 @@ public class PublishOperation extends AbstractOperation<PublishOperation> {
offline(project.offline());
properties(project.properties());
artifactRetriever(project.artifactRetriever());
dependencyRepositories(project.repositories());
dependencies().include(project.dependencies());
artifacts(List.of(
new PublishArtifact(new File(project.buildDistDirectory(), project.jarFileName()), "", TYPE_JAR),
@ -733,6 +780,33 @@ public class PublishOperation extends AbstractOperation<PublishOperation> {
return this;
}
/**
* Provides a list of repositories to resolve the dependencies against.
* <p>
* These are used to resolve the versions of dependencies that were
* declared without one, they are not publication targets.
*
* @param repositories a list of repositories against which dependencies will be resolved
* @return this operation instance
* @since 2.4.0
*/
public PublishOperation dependencyRepositories(List<Repository> repositories) {
dependencyRepositories_.addAll(repositories);
return this;
}
/**
* Retrieves the repositories that dependencies will be resolved against.
* <p>
* This is a modifiable list that can be retrieved and changed.
*
* @return the repositories used for dependency resolution
* @since 2.4.0
*/
public List<Repository> dependencyRepositories() {
return dependencyRepositories_;
}
/**
* Provides scoped dependencies to reference in the publication.
*

View file

@ -12,6 +12,8 @@ import rife.tools.StringUtils;
import rife.tools.exceptions.FileUtilsErrorException;
import java.io.File;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Objects;
import static rife.bld.dependencies.Dependency.TYPE_JAR;
@ -152,6 +154,7 @@ public class PomBuilder {
}
if (dependencies() != null && !dependencies().isEmpty()) {
addDependencyManagement(t);
addDependencies(t, Scope.compile);
addDependencies(t, Scope.runtime);
addDependencies(t, Scope.provided);
@ -172,6 +175,32 @@ public class PomBuilder {
FileUtils.writeString(pomBuilder.build(), file);
}
private void addDependencyManagement(Template t) {
var boms = new LinkedHashMap<String, Bom>();
for (var scope : List.of(Scope.compile, Scope.runtime, Scope.provided)) {
var scoped_dependencies = dependencies().get(scope);
if (scoped_dependencies == null) {
continue;
}
for (var bom : scoped_dependencies.boms()) {
if (!bom.version().equals(VersionNumber.UNKNOWN)) {
boms.putIfAbsent(bom.toArtifactString(), bom);
}
}
}
if (boms.isEmpty()) {
return;
}
for (var bom : boms.values()) {
t.setValueEncoded("dependency-management-groupId", bom.groupId());
t.setValueEncoded("dependency-management-artifactId", bom.artifactId());
t.setValueEncoded("dependency-management-version", bom.version());
t.appendBlock("dependency-management", "dependency-management-entry");
}
t.setBlock("dependency-management-tag");
}
private void addDependencies(Template t, Scope scope) {
var scoped_dependencies = dependencies().scope(scope);
if (!scoped_dependencies.isEmpty()) {

View file

@ -32,6 +32,24 @@
</properties>
<!--/b-->
<!--v dependency-management-tag--><!--/v-->
<!--b dependency-management-tag-->
<dependencyManagement>
<dependencies>
<!--v dependency-management--><!--/v-->
<!--b dependency-management-entry-->
<dependency>
<groupId><!--v dependency-management-groupId--><!--/v--></groupId>
<artifactId><!--v dependency-management-artifactId--><!--/v--></artifactId>
<version><!--v dependency-management-version--><!--/v--></version>
<type>pom</type>
<scope>import</scope>
</dependency>
<!--/b-->
</dependencies>
</dependencyManagement>
<!--/b-->
<!--v dependencies-tag--><!--/v-->
<!--b dependencies-tag-->
<dependencies>

View file

@ -11,9 +11,11 @@ import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.DisabledOnOs;
import org.junit.jupiter.api.condition.OS;
import rife.bld.Project;
import rife.bld.WebProject;
import rife.bld.blueprints.AppProjectBlueprint;
import rife.bld.dependencies.*;
import rife.bld.publish.PublishArtifact;
import rife.bld.publish.PublishInfo;
import rife.tools.FileUtils;
import rife.tools.exceptions.FileUtilsErrorException;
@ -704,4 +706,246 @@ public class TestPublishOperation {
FileUtils.deleteDirectory(tmp1);
}
}
@Test
void testPublishLocalWithBom()
throws Exception {
var poms = java.util.Map.of(
"bom1:1.0.0", """
<?xml version="1.0" encoding="UTF-8"?>
<project>
<modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId>
<artifactId>bom1</artifactId>
<version>1.0.0</version>
<packaging>pom</packaging>
<dependencyManagement>
<dependencies>
<dependency><groupId>com.example</groupId><artifactId>a</artifactId><version>1.4.0</version></dependency>
</dependencies>
</dependencyManagement>
</project>""");
var metadata = java.util.Map.of(
"b", """
<?xml version="1.0" encoding="UTF-8"?>
<metadata>
<groupId>com.example</groupId>
<artifactId>b</artifactId>
<versioning>
<latest>2.2.0</latest>
<release>2.2.0</release>
<versions><version>2.2.0</version></versions>
</versioning>
</metadata>""");
var server = com.sun.net.httpserver.HttpServer.create(new java.net.InetSocketAddress("localhost", 0), 0);
server.createContext("/", exchange -> {
var segments = exchange.getRequestURI().getPath().split("/");
var filename = segments[segments.length - 1];
String content = null;
if (filename.endsWith(".pom") && segments.length >= 3) {
content = poms.get(segments[segments.length - 3] + ":" + segments[segments.length - 2]);
} else if (filename.equals("maven-metadata.xml") && segments.length >= 2) {
content = metadata.get(segments[segments.length - 2]);
}
if (content == null) {
exchange.sendResponseHeaders(404, -1);
} else {
var body = content.getBytes();
exchange.sendResponseHeaders(200, body.length);
exchange.getResponseBody().write(body);
}
exchange.close();
});
server.start();
var tmp_local = Files.createTempDirectory("bomlocal").toFile();
var artifact_file = File.createTempFile("myapp", ".jar");
try {
var operation = new PublishOperation()
.artifactRetriever(ArtifactRetriever.cachingInstance())
.repositories(new Repository(tmp_local.getAbsolutePath()))
.dependencyRepositories(List.of(new Repository("http://localhost:" + server.getAddress().getPort() + "/")))
.info(new PublishInfo()
.groupId("test.pkg")
.artifactId("myapp")
.version(new VersionNumber(3, 3, 3)))
.artifacts(List.of(new PublishArtifact(artifact_file, "", "jar")));
operation.dependencies().scope(Scope.compile)
.include(new Bom("com.example", "bom1", new VersionNumber(1, 0, 0)))
.include(new Dependency("com.example", "a"))
.include(new Dependency("com.example", "b"));
operation.execute();
var pom = FileUtils.readString(Path.of(tmp_local.getAbsolutePath(), "test", "pkg", "myapp", "3.3.3", "myapp-3.3.3.pom").toFile());
// the BOM is imported through dependency management
assertTrue(pom.contains("<dependencyManagement>"), pom);
assertTrue(pom.contains("<artifactId>bom1</artifactId>"), pom);
assertTrue(pom.contains("<version>1.0.0</version>"), pom);
assertTrue(pom.contains("<type>pom</type>"), pom);
assertTrue(pom.contains("<scope>import</scope>"), pom);
// the covered dependency stays version-less,
// the BOM import provides its version downstream
assertTrue(pom.contains("<artifactId>a</artifactId>"), pom);
assertFalse(java.util.regex.Pattern.compile("<artifactId>a</artifactId>\\s*<version>").matcher(pom).find(), pom);
// the uncovered dependency is frozen to its resolved version
assertTrue(java.util.regex.Pattern.compile("<artifactId>b</artifactId>\\s*<version>2\\.2\\.0</version>").matcher(pom).find(), pom);
} finally {
server.stop(0);
artifact_file.delete();
FileUtils.deleteDirectory(tmp_local);
}
}
@Test
void testResolveVersionlessDependenciesUntouchedScopes() {
// no network access is needed when nothing has to be resolved :
// explicitly versioned dependencies are untouched and the test
// scope isn't part of the publication
var scopes = new DependencyScopes();
scopes.scope(Scope.compile)
.include(new Dependency("com.example", "c", new VersionNumber(5, 0, 0)));
scopes.scope(Scope.test)
.include(new Dependency("com.example", "t"));
var operation = new PublishOperation()
.dependencies(scopes);
operation.executeResolveVersionlessDependencies();
assertEquals(new VersionNumber(5, 0, 0), operation.dependencies().scope(Scope.compile).get(new Dependency("com.example", "c")).version());
assertEquals(VersionNumber.UNKNOWN, operation.dependencies().scope(Scope.test).get(new Dependency("com.example", "t")).version());
}
static class BomPublishProject extends WebProject {
BomPublishProject(File tmp) {
workDirectory = tmp;
pkg = "test.pkg";
name = "myapp";
version = new VersionNumber(0, 0, 1);
repositories = List.of(new Repository("https://example.com/resolve"));
}
}
@Test
void testFromProjectDependencyRepositories()
throws Exception {
var tmp = Files.createTempDirectory("test").toFile();
try {
var project = new BomPublishProject(tmp);
var operation = new PublishOperation()
.fromProject(project);
assertEquals(project.repositories(), operation.dependencyRepositories());
} finally {
FileUtils.deleteDirectory(tmp);
}
}
@Test
void testResolveVersionlessDependencies()
throws Exception {
var poms = java.util.Map.of(
"bom1:1.0.0", """
<?xml version="1.0" encoding="UTF-8"?>
<project>
<modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId>
<artifactId>bom1</artifactId>
<version>1.0.0</version>
<packaging>pom</packaging>
<dependencyManagement>
<dependencies>
<dependency><groupId>com.example</groupId><artifactId>a</artifactId><version>1.4.0</version></dependency>
</dependencies>
</dependencyManagement>
</project>""",
"bom2:3.0.0", """
<?xml version="1.0" encoding="UTF-8"?>
<project>
<modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId>
<artifactId>bom2</artifactId>
<version>3.0.0</version>
<packaging>pom</packaging>
<dependencyManagement>
<dependencies>
<dependency><groupId>com.example</groupId><artifactId>d</artifactId><version>4.0.0</version></dependency>
</dependencies>
</dependencyManagement>
</project>""");
var metadata = java.util.Map.of(
"b", """
<?xml version="1.0" encoding="UTF-8"?>
<metadata>
<groupId>com.example</groupId>
<artifactId>b</artifactId>
<versioning>
<latest>2.2.0</latest>
<release>2.2.0</release>
<versions><version>2.2.0</version></versions>
</versioning>
</metadata>""",
"bom2", """
<?xml version="1.0" encoding="UTF-8"?>
<metadata>
<groupId>com.example</groupId>
<artifactId>bom2</artifactId>
<versioning>
<latest>3.0.0</latest>
<release>3.0.0</release>
<versions><version>3.0.0</version></versions>
</versioning>
</metadata>""");
var server = com.sun.net.httpserver.HttpServer.create(new java.net.InetSocketAddress("localhost", 0), 0);
server.createContext("/", exchange -> {
var segments = exchange.getRequestURI().getPath().split("/");
var filename = segments[segments.length - 1];
String content = null;
if (filename.endsWith(".pom") && segments.length >= 3) {
content = poms.get(segments[segments.length - 3] + ":" + segments[segments.length - 2]);
} else if (filename.equals("maven-metadata.xml") && segments.length >= 2) {
content = metadata.get(segments[segments.length - 2]);
}
if (content == null) {
exchange.sendResponseHeaders(404, -1);
} else {
var body = content.getBytes();
exchange.sendResponseHeaders(200, body.length);
exchange.getResponseBody().write(body);
}
exchange.close();
});
server.start();
try {
var scopes = new DependencyScopes();
scopes.scope(Scope.compile)
.include(new Bom("com.example", "bom1", new VersionNumber(1, 0, 0)))
.include(new Dependency("com.example", "a"))
.include(new Dependency("com.example", "b"))
.include(new Dependency("com.example", "c", new VersionNumber(5, 0, 0)));
scopes.scope(Scope.runtime)
.include(new Bom("com.example", "bom2"));
var operation = new PublishOperation()
.artifactRetriever(ArtifactRetriever.cachingInstance())
.dependencyRepositories(List.of(new Repository("http://localhost:" + server.getAddress().getPort() + "/")))
.dependencies(scopes);
operation.executeResolveVersionlessDependencies();
var compile_scope = operation.dependencies().scope(Scope.compile);
// covered by the BOM : stays version-less for the POM,
// the dependency management import provides the version
assertEquals(VersionNumber.UNKNOWN, compile_scope.get(new Dependency("com.example", "a")).version());
// not covered : resolved to its latest version
assertEquals(new VersionNumber(2, 2, 0), compile_scope.get(new Dependency("com.example", "b")).version());
// explicitly versioned : untouched
assertEquals(new VersionNumber(5, 0, 0), compile_scope.get(new Dependency("com.example", "c")).version());
// a version-less BOM is resolved to its latest version
var runtime_boms = operation.dependencies().scope(Scope.runtime).boms();
assertEquals(new Bom("com.example", "bom2", new VersionNumber(3, 0, 0)), runtime_boms.iterator().next());
} finally {
server.stop(0);
}
}
}

View file

@ -652,4 +652,93 @@ public class TestPomBuilder {
</project>
""", builder.build());
}
@Test
void testDependencyManagementScopeBoundaries() {
var builder = new PomBuilder();
builder.dependencies().scope(Scope.provided)
.include(new Bom("org.eclipse.jetty", "jetty-bom", new VersionNumber(12, 0, 16)));
builder.dependencies().scope(Scope.test)
// the test scope isn't published, its BOMs are not emitted
.include(new Bom("org.junit", "junit-bom", new VersionNumber(6, 1, 1)));
assertEquals("""
<?xml version="1.0" encoding="UTF-8"?>
<project xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd" xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<modelVersion>4.0.0</modelVersion>
<groupId></groupId>
<artifactId></artifactId>
<version></version>
<name></name>
<description></description>
<url></url>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.eclipse.jetty</groupId>
<artifactId>jetty-bom</artifactId>
<version>12.0.16</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
</dependencies>
</project>
""", builder.build());
}
@Test
void testDependencyManagement() {
var builder = new PomBuilder();
builder.dependencies().scope(Scope.compile)
.include(new Bom("io.vertx", "vertx-stack-depchain", new VersionNumber(4, 5, 12)))
.include(new Dependency("io.vertx", "vertx-core"));
builder.dependencies().scope(Scope.runtime)
// duplicate BOMs are only emitted once
.include(new Bom("io.vertx", "vertx-stack-depchain", new VersionNumber(4, 5, 12)))
.include(new Bom("org.junit", "junit-bom", new VersionNumber(6, 1, 1)));
builder.dependencies().scope(Scope.provided)
// BOMs without a version are not emitted
.include(new Bom("com.example", "versionless-bom"));
assertEquals("""
<?xml version="1.0" encoding="UTF-8"?>
<project xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd" xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<modelVersion>4.0.0</modelVersion>
<groupId></groupId>
<artifactId></artifactId>
<version></version>
<name></name>
<description></description>
<url></url>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>io.vertx</groupId>
<artifactId>vertx-stack-depchain</artifactId>
<version>4.5.12</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<dependency>
<groupId>org.junit</groupId>
<artifactId>junit-bom</artifactId>
<version>6.1.1</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>io.vertx</groupId>
<artifactId>vertx-core</artifactId>
<scope>compile</scope>
</dependency>
</dependencies>
</project>
""", builder.build());
}
}