001/* 002 * JDrupes Builder 003 * Copyright (C) 2025 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.BufferedOutputStream; 024import java.io.FileNotFoundException; 025import java.io.IOException; 026import java.io.InputStream; 027import java.io.OutputStream; 028import java.io.PipedInputStream; 029import java.io.PipedOutputStream; 030import java.io.UncheckedIOException; 031import java.io.UnsupportedEncodingException; 032import java.net.URI; 033import java.net.URISyntaxException; 034import java.net.URLEncoder; 035import java.net.http.HttpClient; 036import java.net.http.HttpRequest; 037import java.net.http.HttpResponse; 038import java.nio.charset.StandardCharsets; 039import java.nio.file.Files; 040import java.nio.file.Path; 041import java.security.MessageDigest; 042import java.security.NoSuchAlgorithmException; 043import java.security.Security; 044import java.util.ArrayList; 045import java.util.Collection; 046import java.util.Collections; 047import java.util.List; 048import java.util.Optional; 049import java.util.concurrent.ExecutorService; 050import java.util.concurrent.Executors; 051import java.util.concurrent.atomic.AtomicBoolean; 052import java.util.concurrent.atomic.AtomicInteger; 053import java.util.function.Supplier; 054import java.util.stream.Stream; 055import java.util.zip.ZipEntry; 056import java.util.zip.ZipOutputStream; 057import org.apache.maven.model.building.DefaultModelBuilderFactory; 058import org.apache.maven.model.building.DefaultModelBuildingRequest; 059import org.apache.maven.model.building.ModelBuildingException; 060import org.apache.maven.model.building.ModelBuildingRequest; 061import org.bouncycastle.bcpg.ArmoredOutputStream; 062import org.bouncycastle.jce.provider.BouncyCastleProvider; 063import org.bouncycastle.openpgp.PGPException; 064import org.bouncycastle.openpgp.PGPPrivateKey; 065import org.bouncycastle.openpgp.PGPPublicKey; 066import org.bouncycastle.openpgp.PGPSecretKeyRingCollection; 067import org.bouncycastle.openpgp.PGPSignature; 068import org.bouncycastle.openpgp.PGPSignatureGenerator; 069import org.bouncycastle.openpgp.PGPUtil; 070import org.bouncycastle.openpgp.operator.jcajce.JcaKeyFingerprintCalculator; 071import org.bouncycastle.openpgp.operator.jcajce.JcaPGPContentSignerBuilder; 072import org.bouncycastle.openpgp.operator.jcajce.JcePBESecretKeyDecryptorBuilder; 073import org.bouncycastle.util.encoders.Base64; 074import org.eclipse.aether.AbstractRepositoryListener; 075import org.eclipse.aether.DefaultRepositorySystemSession; 076import org.eclipse.aether.RepositoryEvent; 077import org.eclipse.aether.artifact.Artifact; 078import org.eclipse.aether.artifact.DefaultArtifact; 079import org.eclipse.aether.deployment.DeployRequest; 080import org.eclipse.aether.deployment.DeploymentException; 081import org.eclipse.aether.repository.RemoteRepository; 082import org.eclipse.aether.repository.RepositoryPolicy; 083import org.eclipse.aether.util.artifact.SubArtifact; 084import org.eclipse.aether.util.repository.AuthenticationBuilder; 085import org.jdrupes.builder.api.BuildContext; 086import org.jdrupes.builder.api.BuildException; 087import org.jdrupes.builder.api.ConfigurationException; 088import org.jdrupes.builder.api.Generator; 089import static org.jdrupes.builder.api.Intent.*; 090import org.jdrupes.builder.api.Project; 091import org.jdrupes.builder.api.Resource; 092import org.jdrupes.builder.api.ResourceRequest; 093import org.jdrupes.builder.core.AbstractGenerator; 094import static org.jdrupes.builder.java.JavaTypes.*; 095import org.jdrupes.builder.java.JavadocJarFile; 096import org.jdrupes.builder.java.LibraryJarFile; 097import org.jdrupes.builder.java.SourcesJarFile; 098import static org.jdrupes.builder.mvnrepo.MvnProperties.ArtifactId; 099import static org.jdrupes.builder.mvnrepo.MvnRepoTypes.*; 100 101/// A [Generator] for maven deployments in response to requests for 102/// [MvnPublication] It supports publishing releases using the 103/// [Publish Portal API](https://central.sonatype.org/publish/publish-portal-api/) 104/// and publishing snapshots using the "traditional" maven approach 105/// (uploading the files, including the appropriate `maven-metadata.xml` 106/// files). 107/// 108/// The publisher requests the [PomFile] from the project and uses 109/// the groupId, artfactId and version as specified in this file. 110/// It also requests the [LibraryJarFile], the [SourcesJarFile] and 111/// the [JavadocJarFile]. The latter two are optional for snapshot 112/// releases. 113/// 114/// Publishing requires credentials for the maven repository and a 115/// PGP/GPG secret key for signing the artifacts. They can be set by 116/// the respective methods. However, it is assumed that the credentials 117/// are usually made available as properties in the build context. 118/// 119@SuppressWarnings({ "PMD.CouplingBetweenObjects", "PMD.ExcessiveImports", 120 "PMD.GodClass", "PMD.TooManyMethods" }) 121public class MvnPublisher extends AbstractGenerator { 122 123 private static final FluentLogger logger = FluentLogger.forEnclosingClass(); 124 private URI uploadUri; 125 private URI snapshotUri; 126 private String repoUser; 127 private String repoPass; 128 private String signingKeyRing; 129 private String signingKeyId; 130 private String signingPassword; 131 private JcaPGPContentSignerBuilder signerBuilder; 132 private PGPPrivateKey privateKey; 133 private PGPPublicKey publicKey; 134 private Supplier<Path> artifactDirectory 135 = () -> project().buildDirectory().resolve("publications/maven"); 136 private boolean keepSubArtifacts; 137 private boolean publishAutomatically; 138 139 /// Creates a new Maven publication generator. 140 /// 141 /// @param project the project 142 /// 143 public MvnPublisher(Project project) { 144 super(project); 145 uploadUri = URI 146 .create("https://central.sonatype.com/api/v1/publisher/upload"); 147 snapshotUri = URI 148 .create("https://central.sonatype.com/repository/maven-snapshots/"); 149 } 150 151 /// Sets the upload URI. 152 /// 153 /// @param uri the repository URI 154 /// @return the maven publication generator 155 /// 156 public MvnPublisher uploadUri(URI uri) { 157 this.uploadUri = uri; 158 return this; 159 } 160 161 /// Returns the upload URI. Defaults to 162 /// `https://central.sonatype.com/api/v1/publisher/upload`. 163 /// 164 /// @return the uri 165 /// 166 public URI uploadUri() { 167 return uploadUri; 168 } 169 170 /// Sets the Maven snapshot repository URI. 171 /// 172 /// @param uri the snapshot repository URI 173 /// @return the maven publication generator 174 /// 175 public MvnPublisher snapshotRepository(URI uri) { 176 this.snapshotUri = uri; 177 return this; 178 } 179 180 /// Returns the snapshot repository. Defaults to 181 /// `https://central.sonatype.com/repository/maven-snapshots/`. 182 /// 183 /// @return the uri 184 /// 185 public URI snapshotRepository() { 186 return snapshotUri; 187 } 188 189 /// Sets the Maven repository credentials. If not specified, the 190 /// publisher looks for properties `mvnrepo.user` and 191 /// `mvnrepo.password` in the properties provided by the [BuildContext]. 192 /// 193 /// @param user the username 194 /// @param pass the password 195 /// @return the maven publication generator 196 /// 197 public MvnPublisher credentials(String user, String pass) { 198 this.repoUser = user; 199 this.repoPass = pass; 200 return this; 201 } 202 203 /// Use the provided information to sign the artifacts. If no 204 /// information is specified, the publisher will use the [BuildContext] 205 /// to look up the properties `signing.secretKeyRingFile`, 206 /// `signing.secretKey` and `signing.password`. 207 /// 208 /// The publisher retrieves the secret key from the key ring using the 209 /// key ID. While this method makes signing in CI/CD pipelines 210 /// more complex, it is considered best practice. 211 /// 212 /// @param secretKeyRing the secret key ring 213 /// @param keyId the key id 214 /// @param password the password 215 /// @return the mvn publisher 216 /// 217 public MvnPublisher signWith(String secretKeyRing, String keyId, 218 String password) { 219 this.signingKeyRing = secretKeyRing; 220 this.signingKeyId = keyId; 221 this.signingPassword = password; 222 return this; 223 } 224 225 /// Keep generated sub artifacts (checksums, signatures). 226 /// 227 /// @return the mvn publication generator 228 /// 229 public MvnPublisher keepSubArtifacts() { 230 keepSubArtifacts = true; 231 return this; 232 } 233 234 /// Publish the release automatically. 235 /// 236 /// @return the mvn publisher 237 /// 238 public MvnPublisher publishAutomatically() { 239 publishAutomatically = true; 240 return this; 241 } 242 243 /// Returns the directory where additional artifacts are created. 244 /// Defaults to sub directory `publications/maven` in the project's 245 /// build directory (see [Project#buildDirectory]). 246 /// 247 /// @return the directory 248 /// 249 public Path artifactDirectory() { 250 return artifactDirectory.get(); 251 } 252 253 /// Sets the directory where additional artifacts are created. 254 /// The [Path] is resolved against the project's build directory 255 /// (see [Project#buildDirectory]). If `destination` is `null`, 256 /// the additional artifacts are created in the directory where 257 /// the base artifact is found. 258 /// 259 /// @param directory the new directory 260 /// @return the maven publication generator 261 /// 262 public MvnPublisher artifactDirectory(Path directory) { 263 if (directory == null) { 264 this.artifactDirectory = () -> null; 265 return this; 266 } 267 this.artifactDirectory 268 = () -> project().buildDirectory().resolve(directory); 269 return this; 270 } 271 272 /// Sets the directory where additional artifacts are created. 273 /// If the [Supplier] returns `null`, the additional artifacts 274 /// are created in the directory where the base artifact is found. 275 /// 276 /// @param directory the new directory 277 /// @return the maven publication generator 278 /// 279 public MvnPublisher artifactDirectory(Supplier<Path> directory) { 280 this.artifactDirectory = directory; 281 return this; 282 } 283 284 @Override 285 protected <T extends Resource> Collection<T> 286 doProvide(ResourceRequest<T> requested) { 287 if (!requested.accepts(MvnPublicationType)) { 288 return Collections.emptyList(); 289 } 290 PomFile pomResource = resourceCheck(project() 291 .resources(of(PomFileType).using(Supply)), "POM file"); 292 if (pomResource == null) { 293 return Collections.emptyList(); 294 } 295 var jarResource = resourceCheck(project() 296 .resources(of(LibraryJarFileType).using(Supply)), "jar file"); 297 if (jarResource == null) { 298 return Collections.emptyList(); 299 } 300 var srcsIter = project() 301 .resources(of(SourcesJarFileType).using(Supply)).iterator(); 302 SourcesJarFile srcsFile = null; 303 if (srcsIter.hasNext()) { 304 srcsFile = srcsIter.next(); 305 if (srcsIter.hasNext()) { 306 logger.atSevere() 307 .log("More than one sources jar resources found."); 308 return Collections.emptyList(); 309 } 310 } 311 var jdIter = project().resources(of(JavadocJarFileType).using(Supply)) 312 .iterator(); 313 JavadocJarFile jdFile = null; 314 if (jdIter.hasNext()) { 315 jdFile = jdIter.next(); 316 if (jdIter.hasNext()) { 317 logger.atSevere() 318 .log("More than one javadoc jar resources found."); 319 return Collections.emptyList(); 320 } 321 } 322 323 // Deploy what we've found 324 @SuppressWarnings("unchecked") 325 var result = (Collection<T>) deploy(pomResource, jarResource, srcsFile, 326 jdFile); 327 return result; 328 } 329 330 private <T extends Resource> T resourceCheck(Stream<T> resources, 331 String name) { 332 var iter = resources.iterator(); 333 if (!iter.hasNext()) { 334 logger.atSevere().log("No %s resource available", name); 335 return null; 336 } 337 var result = iter.next(); 338 if (iter.hasNext()) { 339 logger.atSevere().log("More than one %s resource found.", name); 340 return null; 341 } 342 return result; 343 } 344 345 private record Deployable(Artifact artifact, boolean temporary) { 346 } 347 348 @SuppressWarnings("PMD.AvoidDuplicateLiterals") 349 private Collection<?> deploy(PomFile pomResource, 350 LibraryJarFile jarResource, SourcesJarFile srcsJar, 351 JavadocJarFile javadocJar) { 352 Artifact mainArtifact; 353 try { 354 mainArtifact = mainArtifact(pomResource); 355 } catch (ModelBuildingException e) { 356 throw new BuildException().from(this).cause(e); 357 } 358 if (artifactDirectory() != null) { 359 artifactDirectory().toFile().mkdirs(); 360 } 361 List<Deployable> toDeploy = new ArrayList<>(); 362 addWithGenerated(toDeploy, new SubArtifact(mainArtifact, "", "pom", 363 pomResource.path().toFile())); 364 addWithGenerated(toDeploy, new SubArtifact(mainArtifact, "", "jar", 365 jarResource.path().toFile())); 366 if (srcsJar != null) { 367 addWithGenerated(toDeploy, new SubArtifact(mainArtifact, "sources", 368 "jar", srcsJar.path().toFile())); 369 } 370 if (javadocJar != null) { 371 addWithGenerated(toDeploy, new SubArtifact(mainArtifact, "javadoc", 372 "jar", javadocJar.path().toFile())); 373 } 374 375 try { 376 if (mainArtifact.isSnapshot()) { 377 deploySnapshot(toDeploy); 378 } else { 379 deployRelease(mainArtifact, toDeploy); 380 } 381 } catch (DeploymentException e) { 382 throw new BuildException().from(this).cause(e); 383 } finally { 384 if (!keepSubArtifacts) { 385 toDeploy.stream().filter(Deployable::temporary).forEach(d -> { 386 d.artifact().getFile().delete(); 387 }); 388 } 389 } 390 return List.of(MvnPublication.of(String.format("%s:%s:%s", 391 mainArtifact.getGroupId(), mainArtifact.getArtifactId(), 392 mainArtifact.getVersion()))); 393 } 394 395 private Artifact mainArtifact(PomFile pomResource) 396 throws ModelBuildingException { 397 var repoSystem = MvnRepoLookup.rootContext().repositorySystem(); 398 var repoSession = MvnRepoLookup.rootContext().repositorySystemSession(); 399 var repos 400 = new ArrayList<>(MvnRepoLookup.rootContext().remoteRepositories()); 401 if (snapshotUri != null) { 402 repos.add(createSnapshotRepository()); 403 } 404 var pomFile = pomResource.path().toFile(); 405 var buildingRequest = new DefaultModelBuildingRequest() 406 .setPomFile(pomFile).setProcessPlugins(false) 407 .setValidationLevel(ModelBuildingRequest.VALIDATION_LEVEL_MINIMAL) 408 .setModelResolver( 409 new MvnModelResolver(repoSystem, repoSession, repos)); 410 var model = new DefaultModelBuilderFactory().newInstance() 411 .build(buildingRequest).getEffectiveModel(); 412 return new DefaultArtifact(model.getGroupId(), model.getArtifactId(), 413 "jar", model.getVersion()); 414 } 415 416 private RemoteRepository createSnapshotRepository() { 417 return new RemoteRepository.Builder( 418 "snapshots", "default", snapshotUri.toString()) 419 .setSnapshotPolicy(new RepositoryPolicy( 420 true, // enable snapshots 421 RepositoryPolicy.UPDATE_POLICY_ALWAYS, 422 RepositoryPolicy.CHECKSUM_POLICY_WARN)) 423 .setReleasePolicy(new RepositoryPolicy( 424 false, 425 RepositoryPolicy.UPDATE_POLICY_NEVER, 426 RepositoryPolicy.CHECKSUM_POLICY_IGNORE)) 427 .build(); 428 } 429 430 private void addWithGenerated(List<Deployable> toDeploy, 431 Artifact artifact) { 432 // Add main artifact 433 toDeploy.add(new Deployable(artifact, false)); 434 435 // Generate .md5 and .sha1 checksum files 436 var artifactFile = artifact.getFile().toPath(); 437 try { 438 MessageDigest md5 = MessageDigest.getInstance("MD5"); 439 MessageDigest sha1 = MessageDigest.getInstance("SHA-1"); 440 try (var fis = Files.newInputStream(artifactFile)) { 441 byte[] buffer = new byte[8192]; 442 while (true) { 443 int read = fis.read(buffer); 444 if (read < 0) { 445 break; 446 } 447 md5.update(buffer, 0, read); 448 sha1.update(buffer, 0, read); 449 } 450 } 451 var fileName = artifactFile.getFileName().toString(); 452 453 // Handle generated md5 454 var md5Path = destinationPath(artifactFile, fileName + ".md5"); 455 Files.writeString(md5Path, toHex(md5.digest())); 456 toDeploy.add(new Deployable(new SubArtifact(artifact, "*", "*.md5", 457 md5Path.toFile()), true)); 458 459 // Handle generated sha1 460 var sha1Path = destinationPath(artifactFile, fileName + ".sha1"); 461 Files.writeString(sha1Path, toHex(sha1.digest())); 462 toDeploy.add(new Deployable(new SubArtifact(artifact, "*", "*.sha1", 463 sha1Path.toFile()), true)); 464 465 // Add signature as yet another artifact 466 var sigPath = signResource(artifactFile); 467 toDeploy.add(new Deployable(new SubArtifact(artifact, "*", "*.asc", 468 sigPath.toFile()), true)); 469 } catch (NoSuchAlgorithmException | IOException | PGPException e) { 470 throw new BuildException().from(this).cause(e); 471 } 472 } 473 474 private Path destinationPath(Path base, String fileName) { 475 var dir = artifactDirectory(); 476 if (dir == null) { 477 base.resolveSibling(fileName); 478 } 479 return dir.resolve(fileName); 480 } 481 482 private static String toHex(byte[] bytes) { 483 char[] hexDigits = "0123456789abcdef".toCharArray(); 484 char[] result = new char[bytes.length * 2]; 485 486 for (int i = 0; i < bytes.length; i++) { 487 int unsigned = bytes[i] & 0xFF; 488 result[i * 2] = hexDigits[unsigned >>> 4]; 489 result[i * 2 + 1] = hexDigits[unsigned & 0x0F]; 490 } 491 return new String(result); 492 } 493 494 private void initSigning() 495 throws FileNotFoundException, IOException, PGPException { 496 if (signerBuilder != null) { 497 return; 498 } 499 var keyRingFileName = Optional.ofNullable(signingKeyRing).orElse( 500 project().context().property("signing.secretKeyRingFile", null)); 501 var keyId = Optional.ofNullable(signingKeyId) 502 .orElse(project().context().property("signing.keyId", null)); 503 var passphrase = Optional.ofNullable(signingPassword) 504 .or(() -> Optional.ofNullable( 505 project().context().property("signing.password", null))) 506 .map(String::toCharArray).orElse(null); 507 if (keyRingFileName == null || keyId == null || passphrase == null) { 508 logger.atWarning() 509 .log("Cannot sign artifacts: properties not set."); 510 return; 511 } 512 Security.addProvider(new BouncyCastleProvider()); 513 var secretKeyRingCollection = new PGPSecretKeyRingCollection( 514 PGPUtil.getDecoderStream( 515 Files.newInputStream(Path.of(keyRingFileName))), 516 new JcaKeyFingerprintCalculator()); 517 var secretKey = secretKeyRingCollection 518 .getSecretKey(Long.parseUnsignedLong(keyId, 16)); 519 publicKey = secretKey.getPublicKey(); 520 privateKey = secretKey.extractPrivateKey( 521 new JcePBESecretKeyDecryptorBuilder().setProvider("BC") 522 .build(passphrase)); 523 signerBuilder = new JcaPGPContentSignerBuilder( 524 publicKey.getAlgorithm(), PGPUtil.SHA256).setProvider("BC"); 525 } 526 527 private Path signResource(Path resource) 528 throws PGPException, IOException { 529 initSigning(); 530 PGPSignatureGenerator signatureGenerator = new PGPSignatureGenerator( 531 signerBuilder, publicKey); 532 signatureGenerator.init(PGPSignature.BINARY_DOCUMENT, privateKey); 533 var sigPath = destinationPath(resource, 534 resource.getFileName() + ".asc"); 535 try (InputStream fileInput = new BufferedInputStream( 536 Files.newInputStream(resource)); 537 OutputStream sigOut 538 = new ArmoredOutputStream(Files.newOutputStream(sigPath))) { 539 byte[] buffer = new byte[8192]; 540 while (true) { 541 int read = fileInput.read(buffer); 542 if (read < 0) { 543 break; 544 } 545 signatureGenerator.update(buffer, 0, read); 546 } 547 PGPSignature signature = signatureGenerator.generate(); 548 signature.encode(sigOut); 549 } 550 return sigPath; 551 } 552 553 private void deploySnapshot(List<Deployable> toDeploy) 554 throws DeploymentException { 555 // Now deploy everything 556 @SuppressWarnings("PMD.CloseResource") 557 var context = MvnRepoLookup.rootContext(); 558 var session = new DefaultRepositorySystemSession( 559 context.repositorySystemSession()); 560 var startMsgLogged = new AtomicBoolean(false); 561 var deployedCount = new AtomicInteger(0); 562 @SuppressWarnings("PMD.CloseResource") 563 var buildContext = context(); 564 session.setRepositoryListener(new AbstractRepositoryListener() { 565 @Override 566 public void artifactDeploying(RepositoryEvent event) { 567 if (!startMsgLogged.getAndSet(true)) { 568 logger.atInfo().log("Start deploying artifacts..."); 569 buildContext.statusLine().update( 570 MvnPublisher.this + " starts deploying artifacts"); 571 } 572 } 573 574 @Override 575 public void artifactDeployed(RepositoryEvent event) { 576 if (!"jar".equals(event.getArtifact().getExtension())) { 577 return; 578 } 579 logger.atInfo().log("Deployed: %s", event.getArtifact()); 580 buildContext.statusLine().update( 581 "%s deployed %d/%d", MvnPublisher.this, 582 deployedCount.incrementAndGet(), toDeploy.size()); 583 } 584 585 @Override 586 public void metadataDeployed(RepositoryEvent event) { 587 logger.atInfo().log("Deployed: %s", event.getMetadata()); 588 buildContext.statusLine().update( 589 "%s deployed %d/%d", MvnPublisher.this, 590 deployedCount.incrementAndGet(), toDeploy.size()); 591 } 592 593 }); 594 var user = Optional.ofNullable(repoUser) 595 .orElse(project().context().property("mvnrepo.user", "")); 596 var password = Optional.ofNullable(repoPass) 597 .orElse(project().context().property("mvnrepo.password", "")); 598 var repo = new RemoteRepository.Builder("mine", "default", 599 snapshotUri.toString()) 600 .setAuthentication(new AuthenticationBuilder() 601 .addUsername(user).addPassword(password).build()) 602 .build(); 603 var deployReq = new DeployRequest().setRepository(repo); 604 toDeploy.stream().map(d -> d.artifact).forEach(deployReq::addArtifact); 605 context.repositorySystem().deploy(session, deployReq); 606 } 607 608 @SuppressWarnings("PMD.AvoidLiteralsInIfCondition") 609 private void deployRelease(Artifact mainArtifact, 610 List<Deployable> toDeploy) { 611 // Create zip file with all artifacts for release, see 612 // https://central.sonatype.org/publish/publish-portal-upload/ 613 var zipName = Optional.ofNullable(project().get(ArtifactId)) 614 .orElse(project().name()) + "-" + mainArtifact.getVersion() 615 + "-release.zip"; 616 var zipPath = artifactDirectory().resolve(zipName); 617 try { 618 Path praefix = Path.of(mainArtifact.getGroupId().replace('.', '/')) 619 .resolve(mainArtifact.getArtifactId()) 620 .resolve(mainArtifact.getVersion()); 621 try (ZipOutputStream zos 622 = new ZipOutputStream(Files.newOutputStream(zipPath))) { 623 for (Deployable d : toDeploy) { 624 var artifact = d.artifact(); 625 @SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops") 626 var entry = new ZipEntry(praefix.resolve( 627 artifact.getArtifactId() + "-" + artifact.getVersion() 628 + (artifact.getClassifier().isEmpty() 629 ? "" 630 : "-" + artifact.getClassifier()) 631 + "." + artifact.getExtension()) 632 .toString()); 633 zos.putNextEntry(entry); 634 try (var fis = Files.newInputStream( 635 artifact.getFile().toPath())) { 636 fis.transferTo(zos); 637 } 638 zos.closeEntry(); 639 } 640 } 641 } catch (IOException e) { 642 throw new BuildException().from(this).cause(e); 643 } 644 645 try (var client = HttpClient.newHttpClient()) { 646 var boundary = "===" + System.currentTimeMillis() + "==="; 647 var user = Optional.ofNullable(repoUser) 648 .orElse(project().context().property("mvnrepo.user", "")); 649 var password = Optional.ofNullable(repoPass) 650 .orElse(project().context().property("mvnrepo.password", "")); 651 var token = new String(Base64.encode((user + ":" + password) 652 .getBytes(StandardCharsets.UTF_8)), StandardCharsets.UTF_8); 653 var effectiveUri = uploadUri; 654 if (publishAutomatically) { 655 effectiveUri = addQueryParameter( 656 uploadUri, "publishingType", "AUTOMATIC"); 657 } 658 HttpRequest request = HttpRequest.newBuilder().uri(effectiveUri) 659 .header("Authorization", "Bearer " + token) 660 .header("Content-Type", 661 "multipart/form-data; boundary=" + boundary) 662 .POST(HttpRequest.BodyPublishers 663 .ofInputStream(() -> getAsMultipart(zipPath, boundary))) 664 .build(); 665 logger.atInfo().log("Uploading release bundle..."); 666 HttpResponse<String> response = client.send(request, 667 HttpResponse.BodyHandlers.ofString()); 668 logger.atFinest().log("Upload response: %s", response.body()); 669 if (response.statusCode() / 100 != 2) { 670 throw new ConfigurationException().from(this).message( 671 "Failed to upload release bundle: " + response.body()); 672 } 673 } catch (IOException | InterruptedException e) { 674 throw new BuildException().from(this).cause(e); 675 } finally { 676 if (!keepSubArtifacts) { 677 zipPath.toFile().delete(); 678 } 679 } 680 } 681 682 @SuppressWarnings("PMD.UseTryWithResources") 683 private InputStream getAsMultipart(Path zipPath, String boundary) { 684 // Use Piped streams for streaming multipart content 685 var fromPipe = new PipedInputStream(); 686 687 // Write multipart content to pipe 688 ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor(); 689 OutputStream toPipe; 690 try { 691 toPipe = new PipedOutputStream(fromPipe); 692 } catch (IOException e) { 693 throw new UncheckedIOException(e); 694 } 695 executor.submit(() -> { 696 try (var mpOut = new BufferedOutputStream(toPipe)) { 697 final String lineFeed = "\r\n"; 698 @SuppressWarnings("PMD.InefficientStringBuffering") 699 StringBuilder intro = new StringBuilder(100) 700 .append("--").append(boundary).append(lineFeed) 701 .append("Content-Disposition: form-data; name=\"bundle\";" 702 + " filename=\"%s\"".formatted(zipPath.getFileName())) 703 .append(lineFeed) 704 .append("Content-Type: application/octet-stream") 705 .append(lineFeed).append(lineFeed); 706 mpOut.write( 707 intro.toString().getBytes(StandardCharsets.US_ASCII)); 708 Files.newInputStream(zipPath).transferTo(mpOut); 709 mpOut.write((lineFeed + "--" + boundary + "--") 710 .getBytes(StandardCharsets.US_ASCII)); 711 } catch (IOException e) { 712 throw new UncheckedIOException(e); 713 } finally { 714 executor.close(); 715 } 716 }); 717 return fromPipe; 718 } 719 720 private static URI addQueryParameter(URI uri, String key, String value) { 721 String query = uri.getQuery(); 722 try { 723 String newQueryParam 724 = key + "=" + URLEncoder.encode(value, "UTF-8"); 725 String newQuery = (query == null || query.isEmpty()) ? newQueryParam 726 : query + "&" + newQueryParam; 727 728 // Build a new URI with the new query string 729 return new URI(uri.getScheme(), uri.getAuthority(), uri.getPath(), 730 newQuery, uri.getFragment()); 731 } catch (UnsupportedEncodingException | URISyntaxException e) { 732 // UnsupportedEncodingException cannot happen, UTF-8 is standard. 733 // URISyntaxException cannot happen when starting with a valid URI 734 throw new IllegalArgumentException(e); 735 } 736 } 737}