001/* 002 * JDrupes Builder 003 * Copyright (C) 2026 Michael N. Lipp 004 * 005 * This program is free software: you can redistribute it and/or modify 006 * it under the terms of the GNU Affero General Public License as 007 * published by the Free Software Foundation, either version 3 of the 008 * License, or (at your option) any later version. 009 * 010 * This program is distributed in the hope that it will be useful, 011 * but WITHOUT ANY WARRANTY; without even the implied warranty of 012 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 013 * GNU Affero General Public License for more details. 014 * 015 * You should have received a copy of the GNU Affero General Public License 016 * along with this program. If not, see <https://www.gnu.org/licenses/>. 017 */ 018 019package org.jdrupes.builder.mvnrepo; 020 021import com.google.common.flogger.FluentLogger; 022import java.io.BufferedOutputStream; 023import java.io.IOException; 024import java.io.InputStream; 025import java.io.OutputStream; 026import java.io.PipedInputStream; 027import java.io.PipedOutputStream; 028import java.io.UncheckedIOException; 029import java.net.URI; 030import java.net.URISyntaxException; 031import java.net.URLEncoder; 032import java.net.http.HttpClient; 033import java.net.http.HttpRequest; 034import java.net.http.HttpResponse; 035import java.nio.charset.StandardCharsets; 036import java.nio.file.Files; 037import java.nio.file.Path; 038import java.time.Duration; 039import java.util.Collections; 040import java.util.List; 041import java.util.Optional; 042import java.util.concurrent.ExecutorService; 043import java.util.concurrent.Executors; 044import java.util.zip.ZipEntry; 045import java.util.zip.ZipOutputStream; 046import org.bouncycastle.util.encoders.Base64; 047import org.eclipse.aether.artifact.Artifact; 048import org.eclipse.aether.resolution.ArtifactRequest; 049import org.eclipse.aether.resolution.ArtifactResolutionException; 050import org.jdrupes.builder.api.BuildContext; 051import org.jdrupes.builder.api.BuildException; 052import org.jdrupes.builder.api.ConfigurationException; 053import static org.jdrupes.builder.mvnrepo.MvnProperties.ArtifactId; 054 055/// A Maven publishing destination that publishes releases using the 056/// [Sonatype Publish Portal API](https://central.sonatype.org/publish/publish-portal-api/). 057/// 058/// Instead of uploading files individually, this implementation of 059/// [MvnPublishingDestination] bundles all artifacts into a single ZIP 060/// release bundle and uploads it via a multipart HTTP request. It is the 061/// modern recommended way to publish releases to Maven Central. 062/// 063public class PortalPublisherDestination extends MvnPublishingDestination { 064 065 private static final FluentLogger logger = FluentLogger.forEnclosingClass(); 066 private boolean publishAutomatically; 067 private URI uploadUri = URI 068 .create("https://central.sonatype.com/api/v1/publisher/upload"); 069 070 /// Initializes a new portal publisher destination. 071 /// 072 /// The id is initialized with "central". This allows the credentials 073 /// from the server section in `settings.xml` with this id to be used 074 /// as fallbacks. 075 /// 076 @SuppressWarnings("PMD.ConstructorCallsOverridableMethod") 077 public PortalPublisherDestination() { 078 super(MvnVersionType.RELEASE); 079 id("central"); 080 } 081 082 @Override 083 public boolean requiresChecksumArtifacts() { 084 return true; 085 } 086 087 /// Publish the release automatically. 088 /// 089 /// @return this destination 090 /// 091 public PortalPublisherDestination publishAutomatically() { 092 publishAutomatically = true; 093 return this; 094 } 095 096 /// Sets the upload URI. 097 /// 098 /// @param uri the repository URI 099 /// @return this destination 100 /// 101 public PortalPublisherDestination uploadUri(URI uri) { 102 this.uploadUri = uri; 103 return this; 104 } 105 106 /// Returns the upload URI. Defaults to 107 /// `https://central.sonatype.com/api/v1/publisher/upload`. 108 /// 109 /// @return the uri 110 /// 111 public URI uploadUri() { 112 return uploadUri; 113 } 114 115 @Override 116 @SuppressWarnings("PMD.AvoidLiteralsInIfCondition") 117 /* default */void publish(BuildContext context, MvnPublisher publisher, 118 Artifact mainArtifact, List<Artifact> toDeploy) { 119 var project = publisher.project(); 120 // Create zip file with all artifacts for release, see 121 // https://central.sonatype.org/publish/publish-portal-upload/ 122 var zipName = Optional.ofNullable(project.get(ArtifactId)) 123 .orElse(project.name()) + "-" + mainArtifact.getVersion() 124 + "-release.zip"; 125 var zipPath = publisher.artifactDirectory().resolve(zipName); 126 try { 127 Path praefix = Path.of(mainArtifact.getGroupId().replace('.', '/')) 128 .resolve(mainArtifact.getArtifactId()) 129 .resolve(mainArtifact.getVersion()); 130 try (ZipOutputStream zos 131 = new ZipOutputStream(Files.newOutputStream(zipPath))) { 132 for (var artifact : toDeploy) { 133 @SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops") 134 var entry = new ZipEntry(praefix.resolve( 135 artifact.getArtifactId() + "-" + artifact.getVersion() 136 + (artifact.getClassifier().isEmpty() 137 ? "" 138 : "-" + artifact.getClassifier()) 139 + "." + artifact.getExtension()) 140 .toString()); 141 zos.putNextEntry(entry); 142 try (var fis = Files.newInputStream( 143 artifact.getPath())) { 144 fis.transferTo(zos); 145 } 146 zos.closeEntry(); 147 } 148 } 149 } catch (IOException e) { 150 throw new BuildException().from(publisher).cause(e); 151 } 152 153 try (var client = HttpClient.newBuilder() 154 .connectTimeout(Duration.ofMinutes(1)).build()) { 155 var boundary = "===" + System.currentTimeMillis() + "==="; 156 var user = repositoryUser(context); 157 var password = repositoryPassword(context); 158 var token = new String(Base64.encode((user + ":" + password) 159 .getBytes(StandardCharsets.UTF_8)), StandardCharsets.UTF_8); 160 var effectiveUri = uploadUri; 161 if (publishAutomatically) { 162 effectiveUri = addQueryParameter( 163 uploadUri, "publishingType", "AUTOMATIC"); 164 } 165 HttpRequest request = HttpRequest.newBuilder().uri(effectiveUri) 166 .timeout(Duration.ofMinutes(10)) 167 .header("Authorization", "Bearer " + token) 168 .header("Content-Type", 169 "multipart/form-data; boundary=" + boundary) 170 .POST(HttpRequest.BodyPublishers 171 .ofInputStream(() -> getAsMultipart(zipPath, boundary))) 172 .build(); 173 logger.atInfo().log("Uploading release bundle..."); 174 HttpResponse<String> response = client.send(request, 175 HttpResponse.BodyHandlers.ofString()); 176 logger.atFinest().log("Upload response: %s", response.body()); 177 if (response.statusCode() / 100 != 2) { 178 throw new ConfigurationException().from(publisher).message( 179 "Failed to upload release bundle: " + response.body()); 180 } 181 } catch (IOException | InterruptedException e) { 182 throw new BuildException().from(publisher).cause(e); 183 } 184 } 185 186 @Override 187 /* default */boolean alreadyPublished(BuildContext context, 188 Artifact mainArtifact) { 189 var artifactRequest = new ArtifactRequest(); 190 artifactRequest.setArtifact(mainArtifact); 191 artifactRequest.setRepositories( 192 Collections.singletonList(MavenContext.mavenCentral())); 193 try { 194 var resolved = MavenContext.repositorySystem().resolveArtifact( 195 MavenContext.repositorySession(), artifactRequest).isResolved(); 196 logger.atFinest().log("Artifact %s already on Maven Central", 197 mainArtifact); 198 return resolved; 199 } catch (ArtifactResolutionException e) { 200 logger.atFinest().log("Artifact %s not on Maven Central: %s", 201 mainArtifact, e.getMessage()); 202 return false; 203 } 204 } 205 206 private static URI addQueryParameter(URI uri, String key, String value) { 207 String query = uri.getQuery(); 208 try { 209 String newQueryParam 210 = key + "=" + URLEncoder.encode(value, StandardCharsets.UTF_8); 211 String newQuery = (query == null || query.isEmpty()) ? newQueryParam 212 : query + "&" + newQueryParam; 213 214 // Build a new URI with the new query string 215 return new URI(uri.getScheme(), uri.getAuthority(), uri.getPath(), 216 newQuery, uri.getFragment()); 217 } catch (URISyntaxException e) { 218 // URISyntaxException cannot happen when starting with a valid URI 219 throw new IllegalArgumentException(e); 220 } 221 } 222 223 @SuppressWarnings("PMD.UseTryWithResources") 224 private InputStream getAsMultipart(Path zipPath, String boundary) { 225 // Use Piped streams for streaming multipart content 226 var fromPipe = new PipedInputStream(); 227 228 // Write multipart content to pipe 229 ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor(); 230 OutputStream toPipe; 231 try { 232 toPipe = new PipedOutputStream(fromPipe); 233 } catch (IOException e) { 234 throw new UncheckedIOException(e); 235 } 236 executor.submit(() -> { 237 try (var mpOut = new BufferedOutputStream(toPipe)) { 238 final String lineFeed = "\r\n"; 239 @SuppressWarnings("PMD.InefficientStringBuffering") 240 StringBuilder intro = new StringBuilder(100) 241 .append("--").append(boundary).append(lineFeed) 242 .append("Content-Disposition: form-data; name=\"bundle\";" 243 + " filename=\"%s\"".formatted(zipPath.getFileName())) 244 .append(lineFeed) 245 .append("Content-Type: application/octet-stream") 246 .append(lineFeed).append(lineFeed); 247 mpOut.write( 248 intro.toString().getBytes(StandardCharsets.US_ASCII)); 249 Files.newInputStream(zipPath).transferTo(mpOut); 250 mpOut.write((lineFeed + "--" + boundary + "--") 251 .getBytes(StandardCharsets.US_ASCII)); 252 } catch (IOException e) { 253 throw new UncheckedIOException(e); 254 } finally { 255 executor.close(); 256 } 257 }); 258 return fromPipe; 259 } 260 261}