001/* 002 * JDrupes Builder 003 * Copyright (C) 2025, 2026 Michael N. Lipp 004 * 005 * This program is free software: you can redistribute it and/or modify 006 * it under the terms of the GNU Affero General Public License as 007 * published by the Free Software Foundation, either version 3 of the 008 * License, or (at your option) any later version. 009 * 010 * This program is distributed in the hope that it will be useful, 011 * but WITHOUT ANY WARRANTY; without even the implied warranty of 012 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 013 * GNU Affero General Public License for more details. 014 * 015 * You should have received a copy of the GNU Affero General Public License 016 * along with this program. If not, see <https://www.gnu.org/licenses/>. 017 */ 018 019package org.jdrupes.builder.mvnrepo; 020 021import com.google.common.flogger.FluentLogger; 022import java.io.BufferedInputStream; 023import java.io.FileNotFoundException; 024import java.io.IOException; 025import java.io.InputStream; 026import java.io.OutputStream; 027import java.net.URI; 028import java.nio.file.Files; 029import java.nio.file.Path; 030import java.security.MessageDigest; 031import java.security.NoSuchAlgorithmException; 032import java.security.Security; 033import java.util.ArrayList; 034import java.util.Arrays; 035import java.util.Collection; 036import java.util.Collections; 037import java.util.List; 038import java.util.Objects; 039import java.util.Optional; 040import java.util.function.Supplier; 041import java.util.stream.Stream; 042import org.apache.maven.model.Model; 043import org.apache.maven.model.building.DefaultModelBuilderFactory; 044import org.apache.maven.model.building.DefaultModelBuildingRequest; 045import org.apache.maven.model.building.ModelBuildingException; 046import org.apache.maven.model.building.ModelBuildingRequest; 047import org.bouncycastle.bcpg.ArmoredOutputStream; 048import org.bouncycastle.jce.provider.BouncyCastleProvider; 049import org.bouncycastle.openpgp.PGPException; 050import org.bouncycastle.openpgp.PGPPrivateKey; 051import org.bouncycastle.openpgp.PGPPublicKey; 052import org.bouncycastle.openpgp.PGPSecretKeyRingCollection; 053import org.bouncycastle.openpgp.PGPSignature; 054import org.bouncycastle.openpgp.PGPSignatureGenerator; 055import org.bouncycastle.openpgp.PGPUtil; 056import org.bouncycastle.openpgp.operator.jcajce.JcaKeyFingerprintCalculator; 057import org.bouncycastle.openpgp.operator.jcajce.JcaPGPContentSignerBuilder; 058import org.bouncycastle.openpgp.operator.jcajce.JcePBESecretKeyDecryptorBuilder; 059import org.eclipse.aether.DefaultRepositorySystemSession; 060import org.eclipse.aether.artifact.Artifact; 061import org.eclipse.aether.artifact.DefaultArtifact; 062import org.eclipse.aether.installation.InstallRequest; 063import org.eclipse.aether.installation.InstallationException; 064import org.eclipse.aether.util.artifact.SubArtifact; 065import org.jdrupes.builder.api.BuildContext; 066import org.jdrupes.builder.api.BuildException; 067import org.jdrupes.builder.api.Generator; 068import static org.jdrupes.builder.api.Intent.*; 069import org.jdrupes.builder.api.Project; 070import org.jdrupes.builder.api.Resource; 071import org.jdrupes.builder.api.ResourceRequest; 072import org.jdrupes.builder.core.AbstractGenerator; 073import static org.jdrupes.builder.java.JavaTypes.*; 074import org.jdrupes.builder.java.JavadocJarFile; 075import org.jdrupes.builder.java.LibraryJarFile; 076import org.jdrupes.builder.java.SourcesJarFile; 077import static org.jdrupes.builder.mvnrepo.MvnRepoTypes.*; 078 079/// A [Generator] for Maven deployments in response to requests for 080/// [MvnPublication] or [MvnInstallation]. It supports publishing 081/// releases using the 082/// [Publish Portal API](https://central.sonatype.org/publish/publish-portal-api/) 083/// and publishing snapshots (and local installations) using the 084/// "traditional" Maven approach (uploading the files individually, 085/// including the appropriate `maven-metadata.xml` files). 086/// 087/// The publisher requests the [PomFile] from the project and uses 088/// the groupId, artfactId and version as specified in this file. 089/// It also requests the [LibraryJarFile], the [SourcesJarFile] and 090/// the [JavadocJarFile]. The latter two are optional for snapshot 091/// releases. 092/// 093/// Publishing requires a PGP/GPG secret key for signing the artifacts. 094/// They can be set by the respective methods. However, it is assumed 095/// that the credentials are usually made available as properties in 096/// the build context. 097/// 098/// Except for local installs, the publisher requires at least one 099/// [MvnPublishingDestination] to publish to. If none is set, the 100/// publisher adds an instance of [PortalPublisherDestination] for releases 101/// and an instance of [MvnDeployDestination] with id "central" 102/// for snapshots. 103/// 104@SuppressWarnings({ "PMD.CouplingBetweenObjects", "PMD.ExcessiveImports", 105 "PMD.GodClass" }) 106public class MvnPublisher extends AbstractGenerator { 107 108 private static final FluentLogger logger = FluentLogger.forEnclosingClass(); 109 private String signingKeyRing; 110 private String signingKeyId; 111 private String signingPassword; 112 private JcaPGPContentSignerBuilder signerBuilder; 113 private PGPPrivateKey privateKey; 114 private PGPPublicKey publicKey; 115 private Supplier<Path> artifactDirectory 116 = () -> project().buildDirectory().resolve("publications/maven"); 117 private boolean keepSubArtifacts; 118 private final List<MvnPublishingDestination> destinations 119 = new ArrayList<>(); 120 121 /// Initializes a new Maven publication generator. 122 /// 123 /// @param project the project 124 /// 125 public MvnPublisher(Project project) { 126 super(project); 127 } 128 129 /// Adds the given publishing destinations. 130 /// 131 /// @param destinations the destinations 132 /// @return the Maven publisher 133 /// 134 public MvnPublisher destinations(MvnPublishingDestination... destinations) { 135 this.destinations.addAll(Arrays.asList(destinations)); 136 return this; 137 } 138 139 /// Create and add a [MvnPublishingDestination] from the given arguments. 140 /// 141 /// @param id the id. May be used to lookup credentials, see 142 /// [MvnPublishingDestination] 143 /// @param uri the location 144 /// @param types the supported version types 145 /// @return the Maven publisher 146 /// 147 public MvnPublisher destination(String id, URI uri, 148 MvnVersionType... types) { 149 destinations.add(new MvnDeployDestination(types) 150 .repositoryUri(Objects.requireNonNull(uri)) 151 .id(Objects.requireNonNull(id))); 152 return this; 153 } 154 155 /// Use the provided information to sign the artifacts. If no 156 /// information is specified, the publisher will use the [BuildContext] 157 /// to look up the properties `signing.secretKeyRingFile`, 158 /// `signing.secretKey` and `signing.password`. 159 /// 160 /// The publisher retrieves the secret key from the key ring using the 161 /// key ID. While this method makes signing in CI/CD pipelines 162 /// more complex, it is considered best practice. 163 /// 164 /// @param secretKeyRing the secret key ring 165 /// @param keyId the key id 166 /// @param password the password 167 /// @return the mvn publisher 168 /// 169 public MvnPublisher signWith(String secretKeyRing, String keyId, 170 String password) { 171 this.signingKeyRing = Objects.requireNonNull(secretKeyRing); 172 this.signingKeyId = Objects.requireNonNull(keyId); 173 this.signingPassword = Objects.requireNonNull(password); 174 return this; 175 } 176 177 /// Keep generated sub artifacts (checksums, signatures). 178 /// 179 /// @return the mvn publication generator 180 /// 181 public MvnPublisher keepSubArtifacts() { 182 keepSubArtifacts = true; 183 return this; 184 } 185 186 /// Returns the directory where additional artifacts are created. 187 /// Defaults to sub directory `publications/maven` in the project's 188 /// build directory (see [Project#buildDirectory]). 189 /// 190 /// @return the directory 191 /// 192 public Path artifactDirectory() { 193 return artifactDirectory.get(); 194 } 195 196 /// Sets the directory where additional artifacts are created. 197 /// The [Path] is resolved against the project's build directory 198 /// (see [Project#buildDirectory]). If `destination` is `null`, 199 /// the additional artifacts are created in the directory where 200 /// the base artifact is found. 201 /// 202 /// @param directory the new directory 203 /// @return the maven publication generator 204 /// 205 public MvnPublisher artifactDirectory(Path directory) { 206 if (directory == null) { 207 this.artifactDirectory = () -> null; 208 return this; 209 } 210 this.artifactDirectory 211 = () -> project().buildDirectory().resolve(directory); 212 return this; 213 } 214 215 /// Sets the directory where additional artifacts are created. 216 /// If the [Supplier] returns `null`, the additional artifacts 217 /// are created in the directory where the base artifact is found. 218 /// 219 /// @param directory the new directory 220 /// @return the maven publication generator 221 /// 222 public MvnPublisher artifactDirectory(Supplier<Path> directory) { 223 this.artifactDirectory = directory; 224 return this; 225 } 226 227 @Override 228 @SuppressWarnings({ "PMD.CognitiveComplexity" }) 229 protected <T extends Resource> Collection<T> 230 doProvide(ResourceRequest<T> requested) { 231 // Check if provided and evaluate for most special type 232 if (!requested.accepts(MvnPublicationType, MvnInstallationType)) { 233 return Collections.emptyList(); 234 } 235 if (requested.accepts(MvnPublicationType) 236 && !requested.isFor(MvnPublicationType)) { 237 @SuppressWarnings("unchecked") 238 var result = (Collection<T>) context().resources(this, project() 239 .of(MvnPublicationType)).toList(); 240 return result; 241 } 242 if (requested.accepts(MvnInstallationType) 243 && !requested.isFor(MvnInstallationType)) { 244 @SuppressWarnings("unchecked") 245 var result = (Collection<T>) context().resources(this, project() 246 .of(MvnInstallationType)).toList(); 247 return result; 248 } 249 250 if (requested.accepts(MvnPublicationType) && destinations.isEmpty()) { 251 destinations(new PortalPublisherDestination(), 252 new MvnDeployDestination( 253 MvnVersionType.SNAPSHOT).id("central")); 254 } 255 PomFile pomResource = resourceCheck(project() 256 .resources(of(PomFileType).using(Supply)), "POM file"); 257 if (pomResource == null) { 258 return Collections.emptyList(); 259 } 260 var jarResource = resourceCheck(project() 261 .resources(of(LibraryJarFileType).using(Supply)), "jar file"); 262 if (jarResource == null) { 263 return Collections.emptyList(); 264 } 265 var srcsIter = project() 266 .resources(of(SourcesJarFileType).using(Supply)).iterator(); 267 SourcesJarFile srcsFile = null; 268 if (srcsIter.hasNext()) { 269 srcsFile = srcsIter.next(); 270 if (srcsIter.hasNext()) { 271 logger.atSevere() 272 .log("More than one sources jar resources found."); 273 return Collections.emptyList(); 274 } 275 } 276 var jdIter = project().resources(of(JavadocJarFileType).using(Supply)) 277 .iterator(); 278 JavadocJarFile jdFile = null; 279 if (jdIter.hasNext()) { 280 jdFile = jdIter.next(); 281 if (jdIter.hasNext()) { 282 logger.atSevere() 283 .log("More than one javadoc jar resources found."); 284 return Collections.emptyList(); 285 } 286 } 287 288 // Deploy what we've found 289 @SuppressWarnings("unchecked") 290 var result = (Collection<T>) publish( 291 pomResource, jarResource, srcsFile, jdFile, 292 requested.accepts(MvnInstallationType)); 293 return result; 294 } 295 296 private <T extends Resource> T resourceCheck(Stream<T> resources, 297 String name) { 298 var iter = resources.iterator(); 299 if (!iter.hasNext()) { 300 logger.atSevere().log("No %s resource available", name); 301 return null; 302 } 303 var result = iter.next(); 304 if (iter.hasNext()) { 305 logger.atSevere().log("More than one %s resource found.", name); 306 return null; 307 } 308 return result; 309 } 310 311 private record Deployable(Artifact artifact, boolean isCheckum, 312 boolean temporary) { 313 } 314 315 @SuppressWarnings("PMD.AvoidDuplicateLiterals") 316 private Collection<?> publish(PomFile pomResource, 317 LibraryJarFile jarResource, SourcesJarFile srcsJar, 318 JavadocJarFile javadocJar, boolean installOnly) { 319 // Create model etc. and check 320 Model model; 321 try { 322 model = buildModel(pomResource); 323 } catch (ModelBuildingException e) { 324 throw new BuildException().from(this).cause(e); 325 } 326 Artifact mainArtifact = new DefaultArtifact(model.getGroupId(), 327 model.getArtifactId(), "jar", model.getVersion()); 328 var coords = String.format("%s:%s:%s", mainArtifact.getGroupId(), 329 mainArtifact.getArtifactId(), mainArtifact.getVersion()); 330 @SuppressWarnings("PMD.CloseResource") 331 var context = context(); 332 var effectiveDests = destinations; 333 if (!mainArtifact.isSnapshot()) { 334 effectiveDests = destinations.stream() 335 .filter(d -> d.accepts(MvnVersionType.RELEASE) 336 && !d.alreadyPublished(context, mainArtifact)) 337 .toList(); 338 // Check if non-snapshot artifact already exists at all destinations 339 if (effectiveDests.isEmpty()) { 340 logger.atInfo().log("Artifact %s already published, skipping", 341 coords); 342 return List.of(MvnPublication.of(coords)); 343 } 344 // Check if it is okay to publish as release 345 checkReleaseDeps(mainArtifact, model); 346 } 347 348 // Assemble all files to deploy 349 List<Deployable> toDeploy = filesToDeploy(pomResource, jarResource, 350 srcsJar, javadocJar, mainArtifact, effectiveDests); 351 352 try { 353 if (installOnly) { 354 install(toDeploy); 355 return List.of(MvnInstallation.of(coords)); 356 } 357 358 effectiveDests.stream().parallel().forEach(destination -> { 359 if (mainArtifact.isSnapshot() 360 ? !destination.accepts(MvnVersionType.SNAPSHOT) 361 : !destination.accepts(MvnVersionType.RELEASE)) { 362 return; 363 } 364 var artifacts = toDeploy.stream().filter(d -> !d.isCheckum() 365 || destination.requiresChecksumArtifacts()) 366 .map(d -> d.artifact).toList(); 367 destination.publish(context, this, mainArtifact, artifacts); 368 }); 369 return List.of(MvnPublication.of(coords)); 370 } finally { 371 if (!keepSubArtifacts) { 372 toDeploy.stream().filter(Deployable::temporary).forEach(d -> { 373 d.artifact().getPath().toFile().delete(); 374 }); 375 } 376 } 377 } 378 379 private Model buildModel(PomFile pomResource) 380 throws ModelBuildingException { 381 var pomFile = pomResource.path().toFile(); 382 var buildingRequest = new DefaultModelBuildingRequest() 383 .setPomFile(pomFile).setProcessPlugins(false) 384 .setValidationLevel(ModelBuildingRequest.VALIDATION_LEVEL_MINIMAL); 385 return new DefaultModelBuilderFactory().newInstance() 386 .build(buildingRequest).getEffectiveModel(); 387 } 388 389 private void checkReleaseDeps(Artifact mainArtifact, Model model) { 390 var snapshotDeps = model.getDependencies().stream() 391 .map(d -> new DefaultArtifact(d.getGroupId(), d.getArtifactId(), 392 d.getClassifier(), d.getType(), d.getVersion())) 393 .filter(Artifact::isSnapshot) 394 .map(a -> a.getGroupId() + ":" + a.getArtifactId() + ":" 395 + a.getVersion()) 396 .toList(); 397 if (!snapshotDeps.isEmpty()) { 398 throw new BuildException().from(this).message( 399 "Release version %s cannot depend on snapshot version(s): %s", 400 mainArtifact, String.join(", ", snapshotDeps)); 401 } 402 } 403 404 private List<Deployable> filesToDeploy(PomFile pomResource, 405 LibraryJarFile jarResource, SourcesJarFile srcsJar, 406 JavadocJarFile javadocJar, Artifact mainArtifact, 407 List<MvnPublishingDestination> effectiveDests) { 408 if (artifactDirectory() != null) { 409 artifactDirectory().toFile().mkdirs(); 410 } 411 List<Deployable> toDeploy = new ArrayList<>(); 412 var needChecksums = effectiveDests.stream() 413 .filter(MvnPublishingDestination::requiresChecksumArtifacts) 414 .findAny().isPresent(); 415 addWithGenerated(toDeploy, new SubArtifact(mainArtifact, "", "pom", 416 pomResource.path().toFile()), needChecksums); 417 addWithGenerated(toDeploy, new SubArtifact(mainArtifact, "", "jar", 418 jarResource.path().toFile()), needChecksums); 419 if (srcsJar != null) { 420 addWithGenerated(toDeploy, new SubArtifact(mainArtifact, "sources", 421 "jar", srcsJar.path().toFile()), needChecksums); 422 } 423 if (javadocJar != null) { 424 addWithGenerated(toDeploy, new SubArtifact(mainArtifact, "javadoc", 425 "jar", javadocJar.path().toFile()), needChecksums); 426 } 427 return toDeploy; 428 } 429 430 private void addWithGenerated(List<Deployable> toDeploy, 431 Artifact artifact, boolean withChecksums) { 432 // Add main artifact 433 toDeploy.add(new Deployable(artifact, false, false)); 434 435 // Generate .md5 and .sha1 checksum files 436 try { 437 if (withChecksums) { 438 generateChecksums(toDeploy, artifact); 439 } 440 441 // Add signature as yet another artifact 442 var sigPath = signResource(artifact.getPath()); 443 toDeploy.add(new Deployable(new SubArtifact(artifact, "*", "*.asc", 444 sigPath.toFile()), false, true)); 445 } catch (NoSuchAlgorithmException | IOException | PGPException e) { 446 throw new BuildException().from(this).cause(e); 447 } 448 } 449 450 private void generateChecksums(List<Deployable> toDeploy, Artifact artifact) 451 throws NoSuchAlgorithmException, IOException { 452 var artifactFile = artifact.getPath(); 453 MessageDigest md5 = MessageDigest.getInstance("MD5"); 454 MessageDigest sha1 = MessageDigest.getInstance("SHA-1"); 455 try (var fis = Files.newInputStream(artifactFile)) { 456 byte[] buffer = new byte[8192]; 457 while (true) { 458 int read = fis.read(buffer); 459 if (read < 0) { 460 break; 461 } 462 md5.update(buffer, 0, read); 463 sha1.update(buffer, 0, read); 464 } 465 } 466 var fileName = artifactFile.getFileName().toString(); 467 468 // Handle generated md5 469 var md5Path = destinationPath(artifactFile, fileName + ".md5"); 470 Files.writeString(md5Path, toHex(md5.digest())); 471 toDeploy 472 .add(new Deployable(new SubArtifact(artifact, "*", "*.md5", 473 md5Path.toFile()), true, true)); 474 475 // Handle generated sha1 476 var sha1Path 477 = destinationPath(artifactFile, fileName + ".sha1"); 478 Files.writeString(sha1Path, toHex(sha1.digest())); 479 toDeploy 480 .add(new Deployable(new SubArtifact(artifact, "*", "*.sha1", 481 sha1Path.toFile()), true, true)); 482 } 483 484 private Path destinationPath(Path base, String fileName) { 485 var dir = artifactDirectory(); 486 if (dir == null) { 487 base.resolveSibling(fileName); 488 } 489 return dir.resolve(fileName); 490 } 491 492 private static String toHex(byte[] bytes) { 493 char[] hexDigits = "0123456789abcdef".toCharArray(); 494 char[] result = new char[bytes.length * 2]; 495 496 for (int i = 0; i < bytes.length; i++) { 497 int unsigned = bytes[i] & 0xFF; 498 result[i * 2] = hexDigits[unsigned >>> 4]; 499 result[i * 2 + 1] = hexDigits[unsigned & 0x0F]; 500 } 501 return new String(result); 502 } 503 504 private void initSigning() 505 throws FileNotFoundException, IOException, PGPException { 506 if (signerBuilder != null) { 507 return; 508 } 509 var keyRingFileName = Optional.ofNullable(signingKeyRing).orElse( 510 project().context().property("signing.secretKeyRingFile", null)); 511 var keyId = Optional.ofNullable(signingKeyId) 512 .orElse(project().context().property("signing.keyId", null)); 513 var passphrase = Optional.ofNullable(signingPassword) 514 .or(() -> Optional.ofNullable( 515 project().context().property("signing.password", null))) 516 .map(String::toCharArray).orElse(null); 517 if (keyRingFileName == null || keyId == null || passphrase == null) { 518 logger.atWarning() 519 .log("Cannot sign artifacts: properties not set."); 520 return; 521 } 522 Security.addProvider(new BouncyCastleProvider()); 523 var secretKeyRingCollection = new PGPSecretKeyRingCollection( 524 PGPUtil.getDecoderStream( 525 Files.newInputStream(Path.of(keyRingFileName))), 526 new JcaKeyFingerprintCalculator()); 527 var secretKey = secretKeyRingCollection 528 .getSecretKey(Long.parseUnsignedLong(keyId, 16)); 529 publicKey = secretKey.getPublicKey(); 530 privateKey = secretKey.extractPrivateKey( 531 new JcePBESecretKeyDecryptorBuilder().setProvider("BC") 532 .build(passphrase)); 533 signerBuilder = new JcaPGPContentSignerBuilder( 534 publicKey.getAlgorithm(), PGPUtil.SHA256).setProvider("BC"); 535 } 536 537 private Path signResource(Path resource) 538 throws PGPException, IOException { 539 initSigning(); 540 PGPSignatureGenerator signatureGenerator = new PGPSignatureGenerator( 541 signerBuilder, publicKey); 542 signatureGenerator.init(PGPSignature.BINARY_DOCUMENT, privateKey); 543 var sigPath = destinationPath(resource, 544 resource.getFileName() + ".asc"); 545 try (InputStream fileInput = new BufferedInputStream( 546 Files.newInputStream(resource)); 547 OutputStream sigOut 548 = new ArmoredOutputStream(Files.newOutputStream(sigPath))) { 549 byte[] buffer = new byte[8192]; 550 while (true) { 551 int read = fileInput.read(buffer); 552 if (read < 0) { 553 break; 554 } 555 signatureGenerator.update(buffer, 0, read); 556 } 557 PGPSignature signature = signatureGenerator.generate(); 558 signature.encode(sigOut); 559 } 560 return sigPath; 561 } 562 563 private void install(List<Deployable> toDeploy) { 564 var session = new DefaultRepositorySystemSession( 565 MavenContext.repositorySession()); 566 var installReq = new InstallRequest(); 567 toDeploy.stream().map(d -> d.artifact).forEach(installReq::addArtifact); 568 try { 569 MavenContext.repositorySystem().install(session, installReq); 570 } catch (InstallationException e) { 571 throw new BuildException().from(this).cause(e); 572 } 573 } 574 575}