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}