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