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