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