entry : results.entrySet()) {
+ String extension = entry.getKey().toLowerCase().replace("-", "");
+ File checksumFile = new File(file.getAbsolutePath() + "." + extension);
+ log.info("generateChecksums: " + extension);
+ if (!checksumFile.exists()) {
+ Files.write(checksumFile.toPath(), entry.getValue().toString().getBytes(StandardCharsets.UTF_8));
+ }
+ checkSumFiles.add(checksumFile);
+ }
+ return checkSumFiles;
+ }
+
+ Model readPomFile(File pomFile) throws MojoExecutionException {
+ try {
+ MavenXpp3Reader reader = new MavenXpp3Reader();
+ try (Reader fileReader = new FileReader(pomFile)) {
+ return reader.read(fileReader);
+ }
+ } catch (XmlPullParserException | IOException e) {
+ throw new MojoExecutionException("Failed to parse POM file.", e);
+ }
+ }
+}
diff --git a/src/main/java/org/apache/maven/plugins/deploy/CentralPortalClient.java b/src/main/java/org/apache/maven/plugins/deploy/CentralPortalClient.java
new file mode 100644
index 00000000..2f73c358
--- /dev/null
+++ b/src/main/java/org/apache/maven/plugins/deploy/CentralPortalClient.java
@@ -0,0 +1,247 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.maven.plugins.deploy;
+
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.io.OutputStreamWriter;
+import java.io.PrintWriter;
+import java.net.HttpURLConnection;
+import java.net.URL;
+import java.nio.file.Files;
+import java.util.Arrays;
+import java.util.Base64;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import org.apache.maven.plugin.MojoExecutionException;
+import org.apache.maven.plugin.logging.Log;
+
+public class CentralPortalClient {
+
+ static final String CENTRAL_PORTAL_URL = "https://central.sonatype.com/api/v1";
+
+ private String username;
+ private String password;
+ private String publishUrl;
+ private Log log;
+
+ public CentralPortalClient() {
+ this.publishUrl = CENTRAL_PORTAL_URL;
+ }
+
+ public void setVariables(String username, String password, String publishUrl, Log log) {
+ this.username = username;
+ this.password = password;
+ this.publishUrl = (publishUrl != null && !publishUrl.trim().isEmpty()) ? publishUrl : CENTRAL_PORTAL_URL;
+ this.log = log;
+ }
+
+ public String upload(File bundle, Boolean autoDeploy) throws IOException {
+ String boundary = "----MavenCentralBoundary" + System.currentTimeMillis();
+ String deployUrl = publishUrl + "/publisher/upload?name=" + bundle.getName();
+ if (autoDeploy) {
+ deployUrl += "&publishingType=AUTOMATIC";
+ } else {
+ deployUrl += "&publishingType=USER_MANAGED";
+ }
+ URL url = new URL(deployUrl);
+ HttpURLConnection conn = (HttpURLConnection) url.openConnection();
+ conn.setDoOutput(true);
+ conn.setInstanceFollowRedirects(true);
+ conn.setRequestMethod("POST");
+ conn.setRequestProperty("Authorization", authHeader());
+ conn.setRequestProperty("Content-Type", "multipart/form-data; boundary=" + boundary);
+
+ try (OutputStream out = conn.getOutputStream();
+ PrintWriter writer = new PrintWriter(new OutputStreamWriter(out))) {
+
+ writer.append("--").append(boundary).append("\r\n");
+ writer.append("Content-Disposition: form-data; name=\"bundle\"; filename=\"")
+ .append(bundle.getName())
+ .append("\"\r\n");
+ writer.append("Content-Type: application/zip\r\n\r\n").flush();
+
+ Files.copy(bundle.toPath(), out);
+ out.flush();
+
+ writer.append("\r\n--").append(boundary).append("--\r\n").flush();
+ }
+
+ int status = conn.getResponseCode();
+ if (status >= 400) {
+ log.error(deployUrl + " returned HTTP error code : " + status);
+ try (InputStream in = conn.getErrorStream()) {
+ String body = readFully(in);
+ log.error("Response body: " + body);
+ } catch (IOException e) {
+ log.error("Failed to read response body", e);
+ }
+ throw new IOException("Failed to upload: HTTP " + status);
+ }
+
+ try (InputStream in = conn.getInputStream()) {
+ return readFully(in);
+ }
+ }
+
+ /**
+ * Query central for the deployment status of the deployment identified with the deploymentId
+ * that was sent when the bundle was uploaded.
+ * Example response:
+ * {@code
+ * {
+ * "deploymentId": "28570f16-da32-4c14-bd2e-c1acc0782365",
+ * "deploymentName": "central-bundle.zip",
+ * "deploymentState": "PUBLISHED",
+ * "purls": [
+ * "pkg:maven/com.sonatype.central.example/example_java_project@0.0.7"
+ * ]
+ * }
+ * }
+ * @param deploymentId the identifier from the upload step
+ * @return the deploymentState part of the response
+ * @throws IOException if the connection could not be established
+ */
+ public String getStatus(String deploymentId) throws IOException {
+ URL url = new URL(publishUrl + "/publisher/status?id=" + deploymentId);
+ HttpURLConnection conn = (HttpURLConnection) url.openConnection();
+ conn.setInstanceFollowRedirects(true);
+ conn.setRequestMethod("POST");
+ conn.setRequestProperty("Authorization", authHeader());
+ int status = conn.getResponseCode();
+ if (status >= 400) {
+ log.error(url + " returned HTTP error code : " + status);
+ try (InputStream in = conn.getErrorStream()) {
+ if (in != null) {
+ String body = readFully(in);
+ log.error("Response body: " + body);
+ }
+ } catch (IOException e) {
+ log.error("Failed to read response body", e);
+ }
+ throw new IOException("Failed to get status: HTTP " + status);
+ }
+ try (InputStream in = conn.getInputStream()) {
+ if (in == null) {
+ return "no response body";
+ }
+ String responseBody = readFully(in);
+ Pattern pattern = Pattern.compile("\"deploymentState\"\\s*:\\s*\"([^\"]+)\"");
+ Matcher matcher = pattern.matcher(responseBody);
+ if (matcher.find()) {
+ return matcher.group(1);
+ } else {
+ return "deploymentState not found in $responseBody";
+ }
+ }
+ }
+
+ private String authHeader() {
+ return "Bearer " + Base64.getEncoder().encodeToString((getUsername() + ":" + getPassword()).getBytes());
+ }
+
+ private String readFully(InputStream in) throws IOException {
+ ByteArrayOutputStream buffer = new ByteArrayOutputStream();
+ byte[] chunk = new byte[8192];
+ int bytesRead;
+ while ((bytesRead = in.read(chunk)) != -1) {
+ buffer.write(chunk, 0, bytesRead);
+ }
+ return buffer.toString("UTF-8").trim();
+ }
+
+ public void uploadAndCheck(File zipBundle, boolean autoDeploy) throws MojoExecutionException {
+ String deploymentId;
+ try {
+ deploymentId = upload(zipBundle, autoDeploy);
+ if (deploymentId == null) {
+ throw new MojoExecutionException("Failed to upload bundle, no deployment id found");
+ }
+ } catch (IOException e) {
+ throw new MojoExecutionException("Failed to upload bundle", e);
+ }
+
+ log.info("Deployment ID: " + deploymentId);
+ log.info("Waiting 10 seconds before checking status...");
+ try {
+ Thread.sleep(10000);
+ String status = getStatus(deploymentId);
+
+ int retries = 10;
+ while (!Arrays.asList("VALIDATED", "PUBLISHING", "PUBLISHED", "FAILED")
+ .contains(status)
+ && retries-- > 0) {
+ log.info("Deploy status is " + status);
+ Thread.sleep(5000);
+ status = getStatus(deploymentId);
+ }
+ switch (status) {
+ case "VALIDATED":
+ log.info("Validated: the project is ready for publishing!");
+ log.info("See https://central.sonatype.com/publishing/deployments for more info");
+ case "PUBLISHING":
+ log.info("Published: Project is publishing on Central!");
+ break;
+ case "PUBLISHED":
+ log.info("Published successfully!");
+ break;
+ default:
+ throw new MojoExecutionException("Release failed with status: " + status);
+ }
+ } catch (InterruptedException | IOException e) {
+ throw new MojoExecutionException("Failed to check status", e);
+ }
+ }
+
+ public Log getLog() {
+ return log;
+ }
+
+ public String getUsername() {
+ return username;
+ }
+
+ public String getPassword() {
+ return password;
+ }
+
+ public String getPublishUrl() {
+ return publishUrl;
+ }
+
+ public void setUsername(String username) {
+ this.username = username;
+ }
+
+ public void setPassword(String password) {
+ this.password = password;
+ }
+
+ public void setPublishUrl(String publishUrl) {
+ this.publishUrl = publishUrl;
+ }
+
+ public void setLog(Log log) {
+ this.log = log;
+ }
+}
diff --git a/src/main/java/org/apache/maven/plugins/deploy/DeployMojo.java b/src/main/java/org/apache/maven/plugins/deploy/DeployMojo.java
index 7cf7a7ef..1f0abd1e 100644
--- a/src/main/java/org/apache/maven/plugins/deploy/DeployMojo.java
+++ b/src/main/java/org/apache/maven/plugins/deploy/DeployMojo.java
@@ -18,11 +18,17 @@
*/
package org.apache.maven.plugins.deploy;
+import javax.inject.Inject;
+
import java.io.File;
+import java.io.IOException;
+import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
+import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
+import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@@ -38,6 +44,11 @@
import org.apache.maven.plugins.annotations.Parameter;
import org.apache.maven.project.MavenProject;
import org.apache.maven.project.artifact.ProjectArtifact;
+import org.apache.maven.settings.Server;
+import org.apache.maven.settings.crypto.DefaultSettingsDecryptionRequest;
+import org.apache.maven.settings.crypto.SettingsDecrypter;
+import org.apache.maven.settings.crypto.SettingsDecryptionRequest;
+import org.apache.maven.settings.crypto.SettingsDecryptionResult;
import org.eclipse.aether.artifact.Artifact;
import org.eclipse.aether.deployment.DeployRequest;
import org.eclipse.aether.repository.RemoteRepository;
@@ -70,7 +81,7 @@ public class DeployMojo extends AbstractDeployMojo {
*
* @since 2.8
*/
- @Parameter(defaultValue = "false", property = "deployAtEnd")
+ @Parameter(defaultValue = "true", property = "deployAtEnd")
private boolean deployAtEnd;
/**
@@ -128,6 +139,38 @@ public class DeployMojo extends AbstractDeployMojo {
@Parameter(property = "maven.deploy.skip", defaultValue = "false")
private String skip = Boolean.FALSE.toString();
+ /**
+ * If false, the deploy plugin will use the legacy deployment api.
+ * If true, the new central portal api will be used.
+ * Default is false.
+ * @since 3.1.5
+ */
+ @Parameter(property = "useCentralPortalApi", defaultValue = "false")
+ private boolean useCentralPortalApi;
+
+ /**
+ * If this is set to false, the bundle will be uploaded to central but not released (published).
+ * You can release it manually at
+ * central deployments. If true, the bundle will be uploaded, validated and then
+ * automatically released if it is a valid deployment bundle.
+ * Default is true i.e. upload and release automatically.
+ * @since 3.1.5
+ */
+ @Parameter(defaultValue = "true", property = "autoDeploy")
+ private boolean autoDeploy;
+
+ /**
+ * Set this to false to create the bundle but not upload it to central.
+ * This is useful if e.g. you want to check it and then manually upload the bundle.
+ * Default is true (i.e. upload it).
+ * @since 3.1.5
+ */
+ @Parameter(defaultValue = "true", property = "uploadToCentral")
+ private boolean uploadToCentral;
+
+ @Inject
+ private SettingsDecrypter settingsDecrypter;
+
/**
* Set this to true to allow incomplete project processing. By default, such projects are forbidden
* and Mojo will fail to process them. Incomplete project is a Maven Project that has any other packaging than
@@ -157,6 +200,9 @@ private enum State {
private static final String DEPLOY_ALT_DEPLOYMENT_REPOSITORY =
DeployMojo.class.getName() + ".altDeploymentRepository";
+ // Make it a member variable to allow test to mock central portal client
+ private CentralPortalClient centralPortalClient = new CentralPortalClient();
+
private void putState(State state) {
getPluginContext().put(DEPLOY_PROCESSED_MARKER, state.name());
}
@@ -200,10 +246,14 @@ public void execute() throws MojoExecutionException, MojoFailureException {
altReleaseDeploymentRepository,
altDeploymentRepository);
- DeployRequest request = new DeployRequest();
- request.setRepository(deploymentRepository);
- processProject(project, request);
- deploy(request);
+ if (useCentralPortalApi) {
+ createAndDeploySingleProjectBundle(deploymentRepository);
+ } else {
+ DeployRequest request = new DeployRequest();
+ request.setRepository(deploymentRepository);
+ processProject(project, request);
+ deploy(request);
+ }
state = State.DEPLOYED;
} else {
putPluginContextValue(DEPLOY_ALT_SNAPSHOT_DEPLOYMENT_REPOSITORY, altSnapshotDeploymentRepository);
@@ -228,7 +278,7 @@ public void execute() throws MojoExecutionException, MojoFailureException {
private void deployAllAtOnce(List allProjectsUsingPlugin) throws MojoExecutionException {
Map requests = new LinkedHashMap<>();
- // collect all arifacts from all modules to deploy
+ // collect all artifacts from all modules to deploy
// requests are grouped by used remote repository
for (MavenProject reactorProject : allProjectsUsingPlugin) {
Map pluginContext = session.getPluginContext(pluginDescriptor, reactorProject);
@@ -249,9 +299,16 @@ private void deployAllAtOnce(List allProjectsUsingPlugin) throws M
processProject(reactorProject, request);
}
}
- // finally execute all deployments request, lets resolver to optimize deployment
- for (DeployRequest request : requests.values()) {
- deploy(request);
+ if (useCentralPortalApi && deployAtEnd) {
+ File zipBundle = createBundle(allProjectsUsingPlugin);
+ if (uploadToCentral) {
+ deployBundle(requests.keySet(), zipBundle);
+ }
+ } else {
+ // finally execute all deployments request, lets resolver to optimize deployment
+ for (DeployRequest request : requests.values()) {
+ deploy(request);
+ }
}
}
@@ -414,4 +471,88 @@ RemoteRepository getDeploymentRepository(
return repo;
}
+
+ protected File createBundle(List allProjectsUsingPlugin) throws MojoExecutionException {
+ if (allProjectsUsingPlugin.isEmpty()) {
+ throw new MojoExecutionException("There are no deployments to process so no bundle to create");
+ }
+ // Locate the mega bundle in the top-level directory of the project
+ // If we use project, it will be the last module built which is semi-random.
+ MavenProject rootProject = project;
+ while (rootProject.getParent() != null) {
+ if (rootProject.getParent().getBasedir().exists()) {
+ rootProject = rootProject.getParent();
+ }
+ }
+ // Since it is a mega bundle (containing all sub projects),
+ // name the zip using groupId and version.
+ File targetDir = new File(rootProject.getBuild().getDirectory());
+ File bundleFile =
+ new File(targetDir, rootProject.getGroupId() + "-" + rootProject.getVersion() + "-bundle.zip");
+
+ try {
+ Bundler bundler = new Bundler(rootProject, getLog());
+ bundler.createZipBundle(bundleFile, allProjectsUsingPlugin);
+ getLog().info("Bundle created successfully: " + bundleFile);
+ } catch (MojoExecutionException e) {
+ throw e;
+ } catch (Exception e) {
+ throw new MojoExecutionException("Failed to create bundle", e);
+ }
+ return bundleFile;
+ }
+
+ private void createAndDeploySingleProjectBundle(RemoteRepository deploymentRepository)
+ throws MojoExecutionException {
+ Bundler bundler = new Bundler(project, getLog());
+ File targetDir = new File(project.getBuild().getDirectory());
+ File bundleFile = new File(targetDir, project.getArtifactId() + "-" + project.getVersion() + "-bundle.zip");
+ try {
+ bundler.createZipBundle(bundleFile);
+ getLog().info("Bundle created successfully: " + bundleFile);
+ } catch (IOException | NoSuchAlgorithmException e) {
+ throw new MojoExecutionException("Failed to create zip bundle", e);
+ }
+ if (uploadToCentral) {
+ deployBundle(Collections.singleton(deploymentRepository), bundleFile);
+ }
+ }
+
+ protected void deployBundle(Set repos, File zipBundle) throws MojoExecutionException {
+ for (RemoteRepository repo : repos) {
+ String[] credentials = resolveCredentials(repo.getId());
+ String username = credentials[0];
+ String password = credentials[1];
+ String deployUrl = repo.getUrl();
+ centralPortalClient.setVariables(username, password, deployUrl, getLog());
+ getLog().info("Deploying " + zipBundle.getName() + " to " + repo.getId() + " at "
+ + centralPortalClient.getPublishUrl());
+ centralPortalClient.uploadAndCheck(zipBundle, autoDeploy);
+ }
+ }
+
+ private String[] resolveCredentials(String serverId) throws MojoExecutionException {
+ Server server = session.getSettings().getServer(serverId);
+ if (server == null) {
+ throw new MojoExecutionException("No entry with id '" + serverId + "' in settings.xml");
+ }
+
+ SettingsDecryptionRequest decryptRequest = new DefaultSettingsDecryptionRequest(server);
+ SettingsDecryptionResult decryptResult = settingsDecrypter.decrypt(decryptRequest);
+ Server decryptedServer = decryptResult.getServer();
+
+ String username = decryptedServer.getUsername();
+ String password = decryptedServer.getPassword();
+
+ if (username == null || password == null) {
+ throw new MojoExecutionException("Missing credentials for server '" + serverId + "' in settings.xml");
+ }
+
+ return new String[] {username, password};
+ }
+
+ // Allow mockito to mock the centralPortalClient
+ void setCentralPortalClient(CentralPortalClient centralPortalClient) {
+ this.centralPortalClient = centralPortalClient;
+ }
}
diff --git a/src/site/apt/examples/deploy-central.apt b/src/site/apt/examples/deploy-central.apt
new file mode 100644
index 00000000..d8852d53
--- /dev/null
+++ b/src/site/apt/examples/deploy-central.apt
@@ -0,0 +1,29 @@
+ ------
+ Deployment of artifacts to Maven Central using the Central Portal API
+ ------
+ Per Nyfelt
+ ------
+ 2025-08-01
+ ------
+
+~~ Licensed to the Apache Software Foundation (ASF) under one
+~~ or more contributor license agreements. See the NOTICE file
+~~ distributed with this work for additional information
+~~ regarding copyright ownership. The ASF licenses this file
+~~ to you under the Apache License, Version 2.0 (the
+~~ "License"); you may not use this file except in compliance
+~~ with the License. You may obtain a copy of the License at
+~~
+~~ http://www.apache.org/licenses/LICENSE-2.0
+~~
+~~ Unless required by applicable law or agreed to in writing,
+~~ software distributed under the License is distributed on an
+~~ "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+~~ KIND, either express or implied. See the License for the
+~~ specific language governing permissions and limitations
+~~ under the License.
+
+~~ NOTE: For help with the syntax of this file, see:
+~~ http://maven.apache.org/doxia/references/apt-format.html
+
+Deployment of artifacts to Maven Central using the Central Portal API
diff --git a/src/site/apt/index.apt.vm b/src/site/apt/index.apt.vm
index 1ad2e42a..34e030fb 100644
--- a/src/site/apt/index.apt.vm
+++ b/src/site/apt/index.apt.vm
@@ -61,7 +61,7 @@ ${project.name}
* Goals Overview
- The deploy plugin has 2 goals:
+ The deploy plugin has 3 goals:
* {{{./deploy-mojo.html}deploy:deploy}} is used to automatically install the
artifact, its pom and the attached artifacts produced by a particular
@@ -73,6 +73,9 @@ ${project.name}
an optionally specified pomFile, but can be completed/overriden using the
command line.
+ * {{{./deploy-bundle-mojo.html}deploy:bundle}} is used when you just want to create a
+ zip bundle that you want to inspect and then manually upload and publish on Maven Central.
+
[]
* Usage
@@ -106,6 +109,8 @@ ${project.name}
* {{{./examples/deploy-network-issues.html}Workarounds when there are network issues}}
+ * {{{./examples/deploy-central.html}Deployment using the Central Portal API}}
+
[]
diff --git a/src/test/java/org/apache/maven/plugins/deploy/CentralDeployTest.java b/src/test/java/org/apache/maven/plugins/deploy/CentralDeployTest.java
new file mode 100644
index 00000000..e9c2f098
--- /dev/null
+++ b/src/test/java/org/apache/maven/plugins/deploy/CentralDeployTest.java
@@ -0,0 +1,379 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.maven.plugins.deploy;
+
+import java.io.File;
+import java.io.IOException;
+import java.lang.reflect.Field;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.util.Collections;
+import java.util.Date;
+import java.util.List;
+import java.util.Objects;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.zip.ZipFile;
+
+import org.apache.maven.artifact.Artifact;
+import org.apache.maven.artifact.DefaultArtifact;
+import org.apache.maven.artifact.handler.ArtifactHandler;
+import org.apache.maven.artifact.handler.DefaultArtifactHandler;
+import org.apache.maven.artifact.handler.manager.ArtifactHandlerManager;
+import org.apache.maven.execution.MavenSession;
+import org.apache.maven.model.DeploymentRepository;
+import org.apache.maven.model.DistributionManagement;
+import org.apache.maven.plugin.MojoExecutionException;
+import org.apache.maven.plugin.testing.AbstractMojoTestCase;
+import org.apache.maven.plugins.deploy.stubs.MavenProjectBigStub;
+import org.apache.maven.project.DefaultMavenProjectHelper;
+import org.apache.maven.project.MavenProjectHelper;
+import org.apache.maven.settings.Server;
+import org.apache.maven.settings.Settings;
+import org.eclipse.aether.DefaultRepositorySystemSession;
+import org.mockito.MockitoAnnotations;
+
+import static org.junit.Assert.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyBoolean;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/**
+ * @author Per Nyfelt
+ */
+public class CentralDeployTest extends AbstractMojoTestCase {
+ private static final String GROUP_ID = "org.apache.maven.test";
+ private static final String ARTIFACT_ID = "central-deploy-test";
+ private static final String VERSION = "1.0.0";
+ private static final String BASE_NAME = ARTIFACT_ID + "-" + VERSION;
+ private static final String SERVER_ID = "central";
+ private static final String SERVER_URL = "http://localhost:8081/api/v1";
+
+ MavenProjectBigStub project;
+
+ private AutoCloseable openMocks;
+
+ private MavenSession session;
+
+ private CentralPortalClient centralPortalClient;
+
+ private DeployMojo mojo;
+
+ private ConcurrentHashMap pluginContext;
+
+ private ArtifactHandler artifactHandler;
+
+ public void setUp() throws Exception {
+ super.setUp();
+ project = new MavenProjectBigStub();
+ session = mock(MavenSession.class);
+ pluginContext = new ConcurrentHashMap<>();
+ Settings settings = mock(Settings.class);
+ Server server = new Server();
+ server.setId(SERVER_ID);
+ server.setUsername("dummy-user");
+ server.setPassword("dummy-password");
+ DefaultRepositorySystemSession repositorySession = new DefaultRepositorySystemSession();
+ when(session.getRepositorySession()).thenReturn(repositorySession);
+ when(session.getPluginContext(any(), any())).thenReturn(pluginContext);
+
+ when(settings.getServer(SERVER_ID)).thenReturn(server);
+ when(session.getSettings()).thenReturn(settings);
+ File testPom = new File(getBasedir(), "target/test-classes/unit/central-deploy-test/plugin-config.xml");
+ mojo = (DeployMojo) lookupMojo("deploy", testPom);
+ openMocks = MockitoAnnotations.openMocks(this);
+ assertNotNull(mojo);
+
+ setVariableValueToObject(mojo, "pluginContext", pluginContext);
+ setVariableValueToObject(mojo, "reactorProjects", Collections.singletonList(project));
+ setVariableValueToObject(mojo, "session", session);
+ setVariableValueToObject(mojo, "project", project);
+ artifactHandler = new DefaultArtifactHandler("jar");
+ project.setDistributionManagement(createDistributionManagement());
+ }
+
+ public void tearDown() throws Exception {
+ super.tearDown();
+
+ if (openMocks != null) {
+ openMocks.close();
+ }
+ }
+
+ /**
+ * (1.0) autoDeploy = true, uploadToCentral = true, deployAtEnd = false
+ * Individual bundles (even when there is only a simple project) are named after the artifactId
+ */
+ public void testCentralPortalAutoDeployTrueDeployAtEndFalse() throws Exception {
+ sunnyDayTest(BASE_NAME + "-bundle.zip", true, false, "central-deploy-test-1");
+ }
+
+ /**
+ * (1.1) autoDeploy = true, uploadToCentral = true, deployAtEnd = true
+ * Mega-bundles are named after the groupId
+ */
+ public void testCentralPortalAutoDeployTrueDeployAtEndTrue() throws Exception {
+ sunnyDayTest(GROUP_ID + "-" + VERSION + "-bundle.zip", true, true, "central-deploy-test-2");
+ }
+
+ public void testCentralPortalAutoDeployFalseDeployAtEndTrue() throws Exception {
+ sunnyDayTest(GROUP_ID + "-" + VERSION + "-bundle.zip", false, true, "central-deploy-test-3");
+ }
+
+ public void testCentralPortalAutoDeployFalseDeployAtEndFalse() throws Exception {
+ sunnyDayTest(BASE_NAME + "-bundle.zip", false, false, "central-deploy-test-4");
+ }
+
+ private void sunnyDayTest(String bundleName, boolean autoDeploy, boolean deployAtEnd, String subDirName)
+ throws Exception {
+
+ setVariableValueToObject(mojo, "useCentralPortalApi", true);
+ setVariableValueToObject(mojo, "autoDeploy", autoDeploy);
+ setVariableValueToObject(mojo, "uploadToCentral", true);
+ setVariableValueToObject(mojo, "deployAtEnd", deployAtEnd);
+
+ centralPortalClient = mock(CentralPortalClient.class);
+ String fakeDeploymentId = "deployment-" + subDirName;
+ when(centralPortalClient.upload(any(File.class), anyBoolean())).thenReturn(fakeDeploymentId);
+ String status = autoDeploy ? "PUBLISHING" : "VALIDATED";
+ when(centralPortalClient.getStatus(fakeDeploymentId)).thenReturn(status);
+ when(centralPortalClient.getPublishUrl()).thenReturn(SERVER_URL);
+ setVariableValueToObject(mojo, "centralPortalClient", centralPortalClient);
+
+ Artifact projectArtifact = createProjectArtifact();
+
+ project.setArtifact(projectArtifact);
+ project.setGroupId(GROUP_ID);
+ project.setArtifactId(ARTIFACT_ID);
+ project.setVersion(VERSION);
+
+ // create a subdir under target to isolate tests from each other
+ File targetSubDir = new File(getBasedir(), project.getBuild().getDirectory() + "/" + subDirName);
+ project.getBuild().setDirectory(targetSubDir.getAbsolutePath());
+ createAndAttachFakeSignedArtifacts(targetSubDir);
+
+ mojo.execute();
+
+ File bundleZip = new File(targetSubDir, bundleName);
+ assertTrue("Expected central bundle zip to be created at " + bundleZip.getAbsolutePath(), bundleZip.exists());
+
+ assertBundleContent(bundleZip);
+ }
+
+ // (5) Negative test: missing .asc files should fail
+ public void testRainySignatureMissing() throws Exception {
+ setVariableValueToObject(mojo, "useCentralPortalApi", true);
+ setVariableValueToObject(mojo, "autoDeploy", true);
+ setVariableValueToObject(mojo, "uploadToCentral", true);
+ setVariableValueToObject(mojo, "deployAtEnd", true);
+
+ centralPortalClient = mock(CentralPortalClient.class);
+ String fakeDeploymentId = "deployment-5";
+ when(centralPortalClient.upload(any(File.class), anyBoolean())).thenReturn(fakeDeploymentId);
+ when(centralPortalClient.getStatus(fakeDeploymentId)).thenReturn("PUBLISHING");
+ when(centralPortalClient.getPublishUrl()).thenReturn(SERVER_URL);
+ setVariableValueToObject(mojo, "centralPortalClient", centralPortalClient);
+
+ Artifact projectArtifact = createProjectArtifact();
+
+ project.setArtifact(projectArtifact);
+ project.setGroupId(GROUP_ID);
+ project.setArtifactId(ARTIFACT_ID);
+ project.setVersion(VERSION);
+
+ // create a subdir under target to isolate tests from each other
+ File targetSubDir = new File(getBasedir(), project.getBuild().getDirectory() + "/central-deploy-test-5");
+ project.getBuild().setDirectory(targetSubDir.getAbsolutePath());
+ createAndAttachFakeSignedArtifacts(targetSubDir);
+ // Remove the pom sign file
+ new File(targetSubDir, ARTIFACT_ID + "-" + VERSION + ".pom.asc").delete();
+
+ MojoExecutionException thrown = assertThrows(MojoExecutionException.class, () -> mojo.execute());
+ assertTrue(
+ "Expected MojoExecutionException to be 'POM file [pomFile] is not signed, [pomFile].asc is missing' but was "
+ + thrown.toString(),
+ thrown.getMessage().contains("pom is not signed,")
+ && thrown.getMessage().contains(".asc is missing"));
+ }
+
+ // (6) Negative test: missing javadoc files should fail
+ public void testRainyJavadocMissing() throws Exception {
+ setVariableValueToObject(mojo, "useCentralPortalApi", true);
+ setVariableValueToObject(mojo, "autoDeploy", false);
+ setVariableValueToObject(mojo, "uploadToCentral", false);
+ setVariableValueToObject(mojo, "deployAtEnd", false);
+
+ centralPortalClient = mock(CentralPortalClient.class);
+ String fakeDeploymentId = "deployment-6";
+ when(centralPortalClient.upload(any(File.class), anyBoolean())).thenReturn(fakeDeploymentId);
+ when(centralPortalClient.getStatus(fakeDeploymentId)).thenReturn("PUBLISHING");
+ when(centralPortalClient.getPublishUrl()).thenReturn(SERVER_URL);
+ setVariableValueToObject(mojo, "centralPortalClient", centralPortalClient);
+
+ Artifact projectArtifact = createProjectArtifact();
+
+ project.setArtifact(projectArtifact);
+ project.setGroupId(GROUP_ID);
+ project.setArtifactId(ARTIFACT_ID);
+ project.setVersion(VERSION);
+
+ // create a subdir under target to isolate tests from each other
+ File targetSubDir = new File(getBasedir(), project.getBuild().getDirectory() + "/central-deploy-test-6");
+ project.getBuild().setDirectory(targetSubDir.getAbsolutePath());
+ createAndAttachFakeSignedArtifacts(targetSubDir);
+ // Remove the javadoc files
+ for (File file : Objects.requireNonNull(targetSubDir.listFiles())) {
+ if (file.getName().contains("-javadoc.")) {
+ assertTrue(file.delete());
+ }
+ }
+
+ MojoExecutionException thrown = assertThrows(MojoExecutionException.class, () -> mojo.execute());
+ assertTrue(
+ "Expected MojoExecutionException to be 'File [javadocFile] is not signed, [javadocFile].asc is missing' but was "
+ + thrown.toString(),
+ thrown.getMessage().contains(" is not signed,")
+ && thrown.getMessage().contains(".asc is missing"));
+ }
+
+ private Artifact createProjectArtifact() {
+ return new DefaultArtifact(
+ GROUP_ID,
+ ARTIFACT_ID,
+ VERSION,
+ null, // scope
+ "jar", // type
+ null, // classifier
+ artifactHandler);
+ }
+
+ private DistributionManagement createDistributionManagement() {
+ DistributionManagement distributionManagement = new DistributionManagement();
+ DeploymentRepository deploymentRepository = new DeploymentRepository();
+ deploymentRepository.setId(SERVER_ID);
+ deploymentRepository.setUrl(SERVER_URL);
+ distributionManagement.setRepository(deploymentRepository);
+ return distributionManagement;
+ }
+
+ // Helper method to create fake signed files for central bundle
+ private void createAndAttachFakeSignedArtifacts(File baseDir)
+ throws IOException, NoSuchFieldException, IllegalAccessException {
+ MavenProjectHelper projectHelper = new DefaultMavenProjectHelper();
+
+ ArtifactHandlerManager artifactHandlerManager = mock(ArtifactHandlerManager.class);
+ when(artifactHandlerManager.getArtifactHandler(any())).thenReturn(artifactHandler);
+ Field handlerField = DefaultMavenProjectHelper.class.getDeclaredField("artifactHandlerManager");
+ handlerField.setAccessible(true);
+ handlerField.set(projectHelper, artifactHandlerManager);
+
+ baseDir.mkdirs();
+
+ File mainJar = new File(baseDir, BASE_NAME + ".jar");
+ File mainJarAsc = new File(baseDir, BASE_NAME + ".jar.asc");
+
+ File sourcesJar = new File(baseDir, BASE_NAME + "-sources.jar");
+ File sourcesAsc = new File(baseDir, BASE_NAME + "-sources.jar.asc");
+
+ File javadocJar = new File(baseDir, BASE_NAME + "-javadoc.jar");
+ File javadocAsc = new File(baseDir, BASE_NAME + "-javadoc.jar.asc");
+
+ File pomFile = new File(baseDir, BASE_NAME + ".pom");
+ File pomAsc = new File(baseDir, BASE_NAME + ".pom.asc");
+
+ // Write fake content
+ List content = Collections.singletonList("generated " + new Date());
+ Files.write(mainJar.toPath(), content, StandardCharsets.UTF_8);
+ Files.write(mainJarAsc.toPath(), Collections.singletonList("signature"), StandardCharsets.UTF_8);
+
+ Files.write(sourcesJar.toPath(), content, StandardCharsets.UTF_8);
+ Files.write(sourcesAsc.toPath(), Collections.singletonList("signature"), StandardCharsets.UTF_8);
+
+ Files.write(javadocJar.toPath(), content, StandardCharsets.UTF_8);
+ Files.write(javadocAsc.toPath(), Collections.singletonList("signature"), StandardCharsets.UTF_8);
+
+ // Write minimal POM XML
+ String pomXml = ""
+ + ""
+ + " 4.0.0"
+ + " " + GROUP_ID + ""
+ + " " + ARTIFACT_ID + ""
+ + " " + VERSION + ""
+ + " Test deployment with sources and javadoc"
+ + " "
+ + " "
+ + " The Apache License, Version 2.0"
+ + " https://www.apache.org/licenses/LICENSE-2.0.txt"
+ + " repo"
+ + " "
+ + " "
+ + " "
+ + " https://github.com/apache/maven-deploy-plugin"
+ + " scm:git:https://github.com/apache/maven-deploy-plugin.git"
+ + " scm:git:https://github.com/apache/maven-deploy-plugin.git"
+ + " "
+ + " "
+ + " "
+ + " jdoe"
+ + " John Doe"
+ + " jdoe@example.com"
+ + " "
+ + " "
+ + "";
+
+ Files.write(pomFile.toPath(), pomXml.getBytes(StandardCharsets.UTF_8));
+ Files.write(pomAsc.toPath(), Collections.singletonList("signature"), StandardCharsets.UTF_8);
+
+ // === Attach main artifacts ===
+ project.getArtifact().setFile(mainJar);
+ project.setFile(pomFile);
+
+ // === Attach other artifacts ===
+ projectHelper.attachArtifact(project, "jar", "sources", sourcesJar);
+ projectHelper.attachArtifact(project, "jar", "javadoc", javadocJar);
+ }
+
+ // Helper method to verify central bundle contents
+ private void assertBundleContent(File bundleZip) throws IOException {
+ String prefix = GROUP_ID.replace('.', '/') + "/" + ARTIFACT_ID + "/" + VERSION + "/";
+ try (ZipFile zip = new ZipFile(bundleZip)) {
+ assertZipHasEntries(
+ zip,
+ prefix,
+ ".pom",
+ ".pom.asc",
+ ".jar",
+ ".jar.asc",
+ "-javadoc.jar",
+ "-javadoc.jar.asc",
+ "-sources.jar",
+ "-sources.jar.asc");
+ }
+ }
+
+ private void assertZipHasEntries(ZipFile zip, String prefix, String... suffixes) {
+ for (String suffix : suffixes) {
+ String entryName = prefix + BASE_NAME + suffix;
+ assertNotNull("Missing zip entry: " + entryName, zip.getEntry(entryName));
+ }
+ }
+}
diff --git a/src/test/java/org/apache/maven/plugins/deploy/stubs/MavenProjectBigStub.java b/src/test/java/org/apache/maven/plugins/deploy/stubs/MavenProjectBigStub.java
new file mode 100644
index 00000000..09421a07
--- /dev/null
+++ b/src/test/java/org/apache/maven/plugins/deploy/stubs/MavenProjectBigStub.java
@@ -0,0 +1,124 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.maven.plugins.deploy.stubs;
+
+import java.io.File;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+import org.apache.maven.artifact.Artifact;
+import org.apache.maven.artifact.repository.ArtifactRepository;
+import org.apache.maven.artifact.repository.DefaultArtifactRepository;
+import org.apache.maven.artifact.repository.layout.DefaultRepositoryLayout;
+import org.apache.maven.model.Build;
+import org.apache.maven.model.DeploymentRepository;
+import org.apache.maven.model.DistributionManagement;
+import org.apache.maven.model.Plugin;
+import org.apache.maven.model.PluginExecution;
+
+public class MavenProjectBigStub extends org.apache.maven.plugin.testing.stubs.MavenProjectStub {
+ private ArtifactRepository deploymentRepository;
+ private Build build;
+ private Artifact artifact;
+ private File file;
+ private final List attachedArtifacts = new ArrayList<>();
+ private DistributionManagement distributionManagement;
+
+ @Override
+ public Artifact getArtifact() {
+ return artifact;
+ }
+
+ @Override
+ public void setArtifact(Artifact artifact) {
+ this.artifact = artifact;
+ }
+
+ @Override
+ public File getFile() {
+ return file;
+ }
+
+ @Override
+ public void setFile(File file) {
+ this.file = file;
+ }
+
+ public ArtifactRepository getDistributionManagementArtifactRepository() {
+ if (deploymentRepository != null) {
+ return deploymentRepository;
+ }
+ if (distributionManagement != null && distributionManagement.getRepository() != null) {
+ DeploymentRepository repo = distributionManagement.getRepository();
+ return new DefaultArtifactRepository(repo.getId(), repo.getUrl(), new DefaultRepositoryLayout());
+ }
+ return null;
+ }
+
+ public void setReleaseArtifactRepository(ArtifactRepositoryStub repo) {
+ this.deploymentRepository = repo;
+ }
+
+ public ArtifactRepository getReleaseArtifactRepository() {
+ return deploymentRepository;
+ }
+
+ @Override
+ public Build getBuild() {
+ if (build == null) {
+ Plugin plugin = new Plugin();
+ plugin.setGroupId("org.apache.maven.plugins");
+ plugin.setArtifactId("maven-deploy-plugin");
+ PluginExecution pluginExecution = new PluginExecution();
+ pluginExecution.setGoals(Collections.singletonList("deploy"));
+ plugin.setExecutions(Collections.singletonList(pluginExecution));
+ Build bld = new Build();
+ bld.setPlugins(Collections.singletonList(plugin));
+ bld.setDirectory("target");
+ this.build = bld;
+ }
+ return build;
+ }
+
+ @Override
+ public void setBuild(Build build) {
+ this.build = build;
+ }
+
+ @Override
+ public void addAttachedArtifact(Artifact artifact) {
+ this.attachedArtifacts.add(artifact);
+ }
+
+ @Override
+ public List getAttachedArtifacts() {
+ return this.attachedArtifacts;
+ }
+
+ @Override
+ public DistributionManagement getDistributionManagement() {
+ return distributionManagement;
+ }
+
+ @Override
+ public void setDistributionManagement(DistributionManagement distributionManagement) {
+ this.distributionManagement = distributionManagement;
+ }
+}
diff --git a/src/test/resources/unit/central-deploy-test/plugin-config.xml b/src/test/resources/unit/central-deploy-test/plugin-config.xml
new file mode 100644
index 00000000..caa16fdc
--- /dev/null
+++ b/src/test/resources/unit/central-deploy-test/plugin-config.xml
@@ -0,0 +1,38 @@
+
+
+ 4.0.0
+ org.apache.maven.test
+ central-deploy-test
+ 1.0.0
+
+
+
+ org.apache.maven.plugins
+ maven-deploy-plugin
+
+
+ true
+ true
+ true
+
+
+
+
+