diff --git a/pom.xml b/pom.xml index 34d3f135..1941ced3 100644 --- a/pom.xml +++ b/pom.xml @@ -243,6 +243,44 @@ under the License. deploy + + + org.eclipse.jetty + jetty-server + 9.4.53.v20231009 + runtime + + + org.eclipse.jetty + jetty-servlet + 9.4.53.v20231009 + runtime + + + javax.servlet + javax.servlet-api + 3.1.0 + runtime + + + commons-io + commons-io + 2.14.0 + runtime + + + com.fasterxml.jackson.core + jackson-databind + 2.13.5 + runtime + + + commons-fileupload + commons-fileupload + 1.4 + runtime + + diff --git a/src/it/central-deploy-bundles/README.md b/src/it/central-deploy-bundles/README.md new file mode 100644 index 00000000..b937487c --- /dev/null +++ b/src/it/central-deploy-bundles/README.md @@ -0,0 +1,51 @@ + +# central-deploy-bundles + +This is a multimodule example showing deployment to maven central +using the new central publishing rest api. + +The project consist of +- an aggregator +- a common sub module +- two sub modules that each depends on the common submodule + +Each module (including the main aggregator) will be deployed separately. +I.e. when deploying the whole project, 4 zip files will be created and uploaded to central: +1. The aggregator pom (+ asc, md5 and sha1 files) +2. common, contains the pom, jar, javadoc and sources (all signed and with md5 and sha1 files) +3. subA, contains the pom, jar, javadoc and sources (all signed and with md5 and sha1 files) +4. subB, contains the pom, jar, javadoc and sources (all signed and with md5 and sha1 files) + +## Running only this test +```shell +mvn -Prun-its verify -Dinvoker.test=central-deploy-bundles +``` + +## Running the test manually +```shell +# copy resources +mvn -Prun-its verify -Dinvoker.test=central-deploy-bundles +cd target/it/central-deploy-bundles +CLASSPATH=$(find "$MAVEN_HOME/lib" -name "*.jar" | tr '\n' ':' | sed 's/:$//') +CLASSPATH=$CLASSPATH:$(./addDependencies.groovy) +groovy -cp $CLASSPATH -Dbasedir=$PWD setup.groovy & +mvn --settings ../../../src/it/settings.xml deploy +groovy -cp $CLASSPATH -Dbasedir=$PWD verify.groovy +``` \ No newline at end of file diff --git a/src/it/central-deploy-bundles/addDependencies.groovy b/src/it/central-deploy-bundles/addDependencies.groovy new file mode 100755 index 00000000..06fa39d7 --- /dev/null +++ b/src/it/central-deploy-bundles/addDependencies.groovy @@ -0,0 +1,87 @@ +#!/usr/bin/env groovy +/* + * 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. + */ +// This script creates a classpath string based on the dependencies for the invoker plugin +// It is used when you want to change the groovy scripts and have more control from the command line +// i.e allows you to run each step manually. See readme.md for details. +import groovy.xml.XmlParser +import groovy.xml.XmlNodePrinter +import groovy.xml.StreamingMarkupBuilder + +// Paths +def realPom = new File("../../../pom.xml") +def tmpDir = File.createTempFile("tmp", "") +tmpDir.delete() +tmpDir.mkdirs() +def tmpPom = new File(tmpDir, "pom.xml") +def depsDir = new File(tmpDir, "deps") +depsDir.mkdirs() + +// Parse original POM without namespaces +def parser = new XmlParser(false, false) // disable namespaces +def pom = parser.parse(realPom) + +// Locate dependencies inside maven-invoker-plugin / run-its profile +def runItsProfile = pom.profiles.profile.find { it.id.text() == "run-its" } +def rawDeps = [] +if (runItsProfile) { + rawDeps = runItsProfile.depthFirst().findAll { + it.name() == "plugin" && it.artifactId.text() == "maven-invoker-plugin" + }*.dependencies*.dependency.flatten() +} + +// Build minimal POM +def newPom = { + mkp.xmlDeclaration() + project(xmlns: "http://maven.apache.org/POM/4.0.0", + 'xmlns:xsi': "http://www.w3.org/2001/XMLSchema-instance", + 'xsi:schemaLocation': "http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd") { + modelVersion("4.0.0") + groupId("temp") + artifactId("invoker-plugin-deps") + version("1.0-SNAPSHOT") + dependencies { + rawDeps.each { dep -> + dependency { + groupId(dep.groupId.text()) + artifactId(dep.artifactId.text()) + version(dep.version.text()) + if (dep.scope) scope(dep.scope.text()) + } + } + } + } +} + +// Write temp POM +tmpPom.text = new StreamingMarkupBuilder().bind(newPom).toString() + +//println "Downloading dependencies..." +def proc = ["mvn", "-q", "-f", tmpPom.absolutePath, + "dependency:copy-dependencies", + "-DoutputDirectory=${depsDir.absolutePath}", + "-DincludeScope=runtime"].execute() +proc.in.eachLine { println it } +proc.err.eachLine { System.err.println it } +proc.waitFor() + +// Build classpath +def jars = depsDir.listFiles().findAll { it.name.endsWith(".jar") } +def classpath = jars.collect { it.absolutePath }.join(":") +println classpath \ No newline at end of file diff --git a/src/it/central-deploy-bundles/common/pom.xml b/src/it/central-deploy-bundles/common/pom.xml new file mode 100644 index 00000000..321a88a9 --- /dev/null +++ b/src/it/central-deploy-bundles/common/pom.xml @@ -0,0 +1,63 @@ + + + + + 4.0.0 + + se.alipsa.maven.example + publishing-example-parent + 1.0.0 + + publishing-example-common + Maven Central Publishing Example, Common + https://github.com/perNyfelt/maven-central-publishing-example + jar + + Maven Central Publishing Example, Common + + + Apache-2.0 + https://www.apache.org/licenses/LICENSE-2.0.txt + + + + + pnyfelt + Per Nyfelt + + + + https://github.com/perNyfelt/maven-central-publishing-example/tree/master + scm:git:https://github.com/perNyfelt/maven-central-publishing-example.git + scm:git:https://github.com/perNyfelt/maven-central-publishing-example.git + + + + + org.apache.maven.plugins + maven-javadoc-plugin + + + org.apache.maven.plugins + maven-source-plugin + + + + \ No newline at end of file diff --git a/src/it/central-deploy-bundles/common/readme.md b/src/it/central-deploy-bundles/common/readme.md new file mode 100644 index 00000000..a1c3e8e1 --- /dev/null +++ b/src/it/central-deploy-bundles/common/readme.md @@ -0,0 +1,19 @@ + +# This is the common module for the multimodule project \ No newline at end of file diff --git a/src/it/central-deploy-bundles/common/src/main/java/multimodule/common/Greeting.java b/src/it/central-deploy-bundles/common/src/main/java/multimodule/common/Greeting.java new file mode 100644 index 00000000..0a9be567 --- /dev/null +++ b/src/it/central-deploy-bundles/common/src/main/java/multimodule/common/Greeting.java @@ -0,0 +1,40 @@ +/* + * 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 multimodule.common; + +/** + * Common interface to define a greeting. + */ +public interface Greeting { + + /** + * Hello {name}. + * + * @param name the name to greet + * @return Hello {name} + */ + String greet(String name); + + /** + * Prints some info. + */ + default void info() { + System.out.println(this.getClass()); + } +} \ No newline at end of file diff --git a/src/it/central-deploy-bundles/fakeSign.groovy b/src/it/central-deploy-bundles/fakeSign.groovy new file mode 100644 index 00000000..b4320453 --- /dev/null +++ b/src/it/central-deploy-bundles/fakeSign.groovy @@ -0,0 +1,86 @@ +/* + * 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. + */ +import org.apache.maven.model.Model +import org.apache.maven.model.io.xpp3.MavenXpp3Reader + +// Since we do not want to mess with external gpg config, we just create fake asc files +signFile(copyPom("pom.xml", "target")) +signJarFiles("target") + +def signJarFiles(String dir) { + def targetDir = new File(getTestDir(), dir) + targetDir.listFiles({ file -> file.name.endsWith(".jar") } as FileFilter).each { + signFile(it) + } +} + +static def signFile(File file) { + def ascFile = new File(file.getParentFile(), "${file.getName()}.asc") + println "Setup: creating signing file: $ascFile" + ascFile << "fake signed" +} + +def copyPom(String from, String toDir) { + File testDir = getTestDir() + def src = new File(testDir, from) + def pomInfo = readPomInfo(src) + def dstDir = new File(testDir, toDir) + dstDir.mkdirs() + def toFile = new File(dstDir, "$pomInfo.artifactId-${pomInfo.version}.pom") + toFile.text = src.text + return toFile +} + +static def readPomInfo(File pomFile) { + MavenXpp3Reader reader = new MavenXpp3Reader() + pomFile.withReader { r -> + Model model = reader.read(r) + + // Inherit groupId/version from parent if missing + if (!model.groupId && model.parent) { + model.groupId = model.parent.groupId + } + if (!model.version && model.parent) { + model.version = model.parent.version + } + + return [ + groupId : model.groupId, + artifactId : model.artifactId, + version : model.version + ] + } +} + +File getTestDir() { + if (binding.hasVariable('project')) { + // from gmavenplus + return binding.getVariable('project').basedir as File + } + def bd + if (binding.hasVariable('basedir')) { + // from the invoker plugin + bd = binding.getVariable('basedir') + } else { + // from command line (e.g. with -Dbasedir=$PWD) + bd = System.getProperty("basedir") + } + bd instanceof File ? bd : new File(bd) +} + diff --git a/src/it/central-deploy-bundles/invoker.properties b/src/it/central-deploy-bundles/invoker.properties new file mode 100644 index 00000000..1f3fd891 --- /dev/null +++ b/src/it/central-deploy-bundles/invoker.properties @@ -0,0 +1,18 @@ +# 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. + +invoker.goals = deploy \ No newline at end of file diff --git a/src/it/central-deploy-bundles/pom.xml b/src/it/central-deploy-bundles/pom.xml new file mode 100644 index 00000000..2017010c --- /dev/null +++ b/src/it/central-deploy-bundles/pom.xml @@ -0,0 +1,161 @@ + + + + 4.0.0 + se.alipsa.maven.example + publishing-example-parent + 1.0.0 + + Maven Central Publishing Example, Aggregator + https://github.com/perNyfelt/maven-central-publishing-example + pom + + + common + subA + + + Maven Central Publishing Example, Aggregator + + + Apache-2.0 + https://www.apache.org/licenses/LICENSE-2.0.txt + + + + + pnyfelt + Per Nyfelt + + + + https://github.com/perNyfelt/maven-central-publishing-example/tree/master + scm:git:https://github.com/perNyfelt/maven-central-publishing-example.git + scm:git:https://github.com/perNyfelt/maven-central-publishing-example.git + + + UTF-8 + UTF-8 + 1.8 + 1.8 + + + + + central + Central Repository + http://localhost:8088/api/v1 + + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + @mavenCompilerPluginVersion@ + + ${maven.compiler.source} + ${maven.compiler.target} + + -Xlint:-options + + + + + org.apache.maven.plugins + maven-deploy-plugin + @project.version@ + + true + false + true + + + + org.apache.maven.plugins + maven-javadoc-plugin + @mavenJavadocPluginVersion@ + + + attach-javadocs + + jar + + + + + + org.apache.maven.plugins + maven-source-plugin + @mavenSourcePluginVersion@ + + + attach-sources + + jar + + + + + + + + + + org.codehaus.gmavenplus + gmavenplus-plugin + 1.13.1 + + + simulate-signing + verify + + execute + + + + fakeSign.groovy + + + + + + + org.apache.groovy + groovy + 4.0.27 + + + org.apache.groovy + groovy-ant + 4.0.27 + + + + + org.apache.maven.plugins + maven-deploy-plugin + + + + \ No newline at end of file diff --git a/src/it/central-deploy-bundles/setup.groovy b/src/it/central-deploy-bundles/setup.groovy new file mode 100644 index 00000000..62b3e6b2 --- /dev/null +++ b/src/it/central-deploy-bundles/setup.groovy @@ -0,0 +1,211 @@ +/* + * 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. + */ +import org.eclipse.jetty.server.Server +import org.eclipse.jetty.servlet.* +import javax.servlet.http.* +import javax.servlet.* +import java.util.concurrent.ConcurrentHashMap +import org.apache.commons.fileupload.servlet.ServletFileUpload +import org.apache.commons.io.IOUtils +import com.fasterxml.jackson.databind.ObjectMapper + +def port = 8088 +def server = new Server(port) +def context = new ServletContextHandler(ServletContextHandler.SESSIONS) +context.setContextPath("/") + +def bundles = new ConcurrentHashMap() +def mapper = new ObjectMapper() +class Bundle { + byte[] content + String name + String publishingType + String fileName +} + +// /api/v1/publisher/upload - Upload endpoint +context.addServlet(new ServletHolder(new HttpServlet() { + protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { + try { + if (!authHeader().equals(req.getHeader("Authorization"))) { + resp.setStatus(401) + println "Bearer token not recognized, should be based on testUser:testPwd" + return + } + + if (!ServletFileUpload.isMultipartContent(req)) { + resp.setStatus(400) + return + } + + def multipartConfig = new MultipartConfigElement(System.getProperty("java.io.tmpdir")) + req.setAttribute("org.eclipse.jetty.multipartConfig", multipartConfig) + + def publishingType = req.getParameter("publishingType") + def name = req.getParameter("name") + def part = req.getPart("bundle") + def zipData = IOUtils.toByteArray(part.inputStream) + if (zipData == null || zipData.length == 0) { + resp.setStatus(400) + resp.setContentType("application/json") + resp.writer.write("bundle contains no data") + return + } + def deploymentId = UUID.randomUUID().toString() + + Bundle bundle = new Bundle() + bundle.content = zipData + bundle.name = name + bundle.publishingType = publishingType + bundle.fileName = part.getSubmittedFileName() + + bundles.put(deploymentId, bundle) + println("Central Simulator received bundle $name, publishingType: $publishingType") + + resp.setContentType("text/plain") + resp.writer.write(deploymentId) + + } catch (Exception e) { + // Log stack trace to console + e.printStackTrace() + + // Respond with HTTP 500 and JSON error details + resp.setStatus(500) + resp.setContentType("application/json") + def errorResponse = [ + error: e.getClass().name, + message: e.message, + stackTrace: e.stackTrace.collect { it.toString() } + ] + resp.writer.write(mapper.writeValueAsString(errorResponse)) + } + } +}), "/api/v1/publisher/upload") + +// /api/v1/publisher/status - Status check +context.addServlet(new ServletHolder(new HttpServlet() { + protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { + try { + if (!authHeader().equals(req.getHeader("Authorization"))) { + resp.setStatus(401) + println "Bearer token not recognized, should be based on testUser:testPwd" + return + } + + def deploymentId = req.getParameter("id") + def bundleInfo = bundles.get(deploymentId) + + if (bundleInfo == null) { + resp.writer.write("Deployment $deploymentId not found") + resp.setStatus(404) + return + } + String deployState = bundleInfo.publishingType == "AUTOMATIC" ? "PUBLISHING" : "VALIDATED" + + resp.setContentType("application/json") + Map deployments = new HashMap<>() + deployments.put(deploymentId, [ + deploymentId: deploymentId, + deploymentState: deployState, + purls: ["pkg:maven/se.alipsa.maven.example/example_java_project@0.0.7"] + ]) + resp.writer.write(mapper.writeValueAsString(deployments)) + } catch (Exception e) { + // Log stack trace to console + e.printStackTrace() + + // Respond with HTTP 500 and JSON error details + resp.setStatus(500) + resp.setContentType("application/json") + def errorResponse = [ + error: e.getClass().name, + message: e.message, + stackTrace: e.stackTrace.collect { it.toString() } + ] + resp.writer.write(mapper.writeValueAsString(errorResponse)) + } + } +}), "/api/v1/publisher/status") + +// /getBundleIds - get a json list of all uploaded bundles (test only api, not part of central api) +context.addServlet(new ServletHolder(new HttpServlet() { + protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { + resp.setContentType("application/json") + List> bundleInfo = new ArrayList() + for (Map.Entry entry : bundles.entrySet()) { + bundleInfo << [ + deploymentId: entry.key, + name: entry.value.name, + publishingType: entry.value.publishingType, + fileName: entry.value.fileName + ] + } + resp.writer.write(mapper.writeValueAsString(bundleInfo)) + } +}), "/getBundleInfo") + +// /getBundle - Retrieve the uploaded bundle by deploymentId (test only api, not part of central api) +context.addServlet(new ServletHolder(new HttpServlet() { + protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { + def id = req.getParameter("deploymentId") + def zip = bundles.get(id) + if (zip == null) { + resp.setStatus(404) + return + } + + resp.setContentType("application/zip") + resp.setHeader("Content-Disposition", "attachment; filename=\"${id}.zip\"") + resp.outputStream.write(zip.content) + } +}), "/getBundle") + +// /shutdown - Shut down the server gracefully (test only api, not part of central api) +context.addServlet(new ServletHolder(new HttpServlet() { + protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { + resp.writer.write("Shutting down...") + new Thread({ + Thread.sleep(1000) + server.stop() + }).start() + } +}), "/shutdown") + +// catch all for everything else (test only api, not part of central api) +context.addServlet(new ServletHolder(new HttpServlet() { + protected void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { + // Log or print the unmatched request URI + println("Unhandled request to: " + req.getRequestURI()) + + resp.setStatus(404) + resp.setContentType("application/json") + resp.writer.write(mapper.writeValueAsString([ + error: "Unknown endpoint", + path: req.getRequestURI() + ])) + } +}), "/*") + +// Must match the user and pwd in settings.xml for the central server +private String authHeader() { + return "Bearer " + Base64.getEncoder().encodeToString(("testUser:testPwd").getBytes()); +} + +server.setHandler(context) +server.start() diff --git a/src/it/central-deploy-bundles/subA/pom.xml b/src/it/central-deploy-bundles/subA/pom.xml new file mode 100644 index 00000000..04244dbe --- /dev/null +++ b/src/it/central-deploy-bundles/subA/pom.xml @@ -0,0 +1,71 @@ + + + + + 4.0.0 + + se.alipsa.maven.example + publishing-example-parent + 1.0.0 + + publishing-example-subA + Maven Central Publishing Example, SubA + https://github.com/perNyfelt/maven-central-publishing-example + jar + + Maven Central Publishing Example, SubA + + + Apache-2.0 + https://www.apache.org/licenses/LICENSE-2.0.txt + + + + + pnyfelt + Per Nyfelt + + + + https://github.com/perNyfelt/maven-central-publishing-example/tree/master + scm:git:https://github.com/perNyfelt/maven-central-publishing-example.git + scm:git:https://github.com/perNyfelt/maven-central-publishing-example.git + + + + se.alipsa.maven.example + publishing-example-common + 1.0.0 + + + + + + + org.apache.maven.plugins + maven-javadoc-plugin + + + org.apache.maven.plugins + maven-source-plugin + + + + \ No newline at end of file diff --git a/src/it/central-deploy-bundles/subA/readme.md b/src/it/central-deploy-bundles/subA/readme.md new file mode 100644 index 00000000..f37eba06 --- /dev/null +++ b/src/it/central-deploy-bundles/subA/readme.md @@ -0,0 +1,19 @@ + +# The subA sub projects of the multmodule project \ No newline at end of file diff --git a/src/it/central-deploy-bundles/subA/src/main/java/multimodule/suba/Hello.java b/src/it/central-deploy-bundles/subA/src/main/java/multimodule/suba/Hello.java new file mode 100644 index 00000000..7593984d --- /dev/null +++ b/src/it/central-deploy-bundles/subA/src/main/java/multimodule/suba/Hello.java @@ -0,0 +1,37 @@ +/* + * 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 multimodule.suba; + +import multimodule.common.Greeting; + +/** + * This is the Greeting implementation in subA + */ +public class Hello implements Greeting { + + /** + * Default constructor. + */ + public Hello() {} + + @Override + public String greet(String name) { + return "Hello $name from suba"; + } +} diff --git a/src/it/central-deploy-bundles/verify.groovy b/src/it/central-deploy-bundles/verify.groovy new file mode 100644 index 00000000..8daad7d9 --- /dev/null +++ b/src/it/central-deploy-bundles/verify.groovy @@ -0,0 +1,127 @@ +/* + * 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. + */ +import groovy.json.JsonSlurper + +import java.nio.file.Files +import java.util.zip.ZipFile + +String getBaseUrl() { + "http://localhost:8088" +} + +URLConnection conn = new URI("$baseUrl/getBundleInfo").toURL().openConnection() +conn.setRequestMethod("GET") +conn.connect() +def json = conn.inputStream.text +List bundles = new JsonSlurper().parseText(json) +println "verify: Received bundles: $bundles" + +// Check that exactly 3 bundles was uploaded +assert bundles.size() == 3 : "Expected exactly 3 bundles, but got ${bundles.size()}" + +String groupId = "se.alipsa.maven.example" +def expectedAggregatorEntries = { String artifactId, String version -> + String basePath = "${groupId.replace('.', '/')}/${artifactId}/${version}/" + + String artifactPath = basePath + artifactId + '-' + version + [ + artifactPath + '.pom', + artifactPath +'.pom.asc', + artifactPath +'.pom.md5', + artifactPath +'.pom.sha1', + artifactPath +'.pom.sha256' + ] +} + +def expectedFullEntries = {String artifactId, String version -> + String basePath = "${groupId.replace('.', '/')}/${artifactId}/${version}/" + String artifactPath = basePath + artifactId + '-' + version + [ + artifactPath + '.jar', + artifactPath + '.jar.asc', + artifactPath + '.jar.md5', + artifactPath + '.jar.sha1', + artifactPath + '.jar.sha256', + artifactPath + '-sources.jar', + artifactPath + '-sources.jar.asc', + artifactPath + '-sources.jar.md5', + artifactPath + '-sources.jar.sha1', + artifactPath + '-sources.jar.sha256', + artifactPath + '-javadoc.jar', + artifactPath + '-javadoc.jar.asc', + artifactPath + '-javadoc.jar.md5', + artifactPath + '-javadoc.jar.sha1', + artifactPath + '-javadoc.jar.sha256' + ] + expectedAggregatorEntries(artifactId, version) +} + +bundles.each { Map info -> + File zipFile = download(info.deploymentId as String) + String fileName = info.fileName + if (fileName.contains('parent')) { + checkZipContent(zipFile, "publishing-example-parent", "1.0.0", expectedAggregatorEntries) + } else if (fileName.contains("common")) { + checkZipContent(zipFile, "publishing-example-common", "1.0.0", expectedFullEntries) + } else if(fileName.contains("subA")) { + checkZipContent(zipFile, "publishing-example-subA", "1.0.0", expectedFullEntries) + } else { + throw new Exception("Unexpected bundle name: ${info.fileName}") + } + zipFile.deleteOnExit() +} + +// download zip content +File download(String deploymentId) { + def conn = new URI("$baseUrl/getBundle?deploymentId=$deploymentId").toURL().openConnection() + conn.setRequestMethod("GET") + conn.connect() + def zip = Files.createTempFile("bundle", ".zip") + def zipFile = zip.toFile() + if (zipFile.exists()) { + zipFile.delete() + } + Files.copy(conn.inputStream, zip) + zipFile +} + +static def checkZipContent(File zipFile, String artifactId, String version, Closure expectedMethod) { + println "Checking content of $zipFile" + List expectedEntries = expectedMethod(artifactId, version) + List actualEntries + try (ZipFile zip = new ZipFile(zipFile)) { + actualEntries = zip.entries().collect { it.name } + } + expectedEntries.each { + //println " - checking $it" + assert actualEntries.contains(it) : "Expected entry not found in $zipFile.name: $it" + } + assert expectedEntries.size() == actualEntries.size() : "Mismatch in number of entries in ZIP" +} + + +// Shut down the server +try { + conn = new URI("$baseUrl/shutdown").toURL().openConnection() + conn.setRequestMethod("POST") + conn.connect() + println conn.inputStream.text + Thread.sleep(1000) +} catch (Exception e) { + println "Shutdown failed: ${e.message}" +} \ No newline at end of file diff --git a/src/it/central-deploy-megabundle/README.md b/src/it/central-deploy-megabundle/README.md new file mode 100644 index 00000000..0a98432c --- /dev/null +++ b/src/it/central-deploy-megabundle/README.md @@ -0,0 +1,50 @@ + +# central-deploy-megabundle + +This is a multimodule example showing deployment to maven central +using the new central publishing rest api. + +The project consist of +- an aggregator +- a common sub module +- two sub modules that each depends on the common submodule + +All modules (including the main aggregator) will be deployed together. +I.e. when deploying the whole project, 1 zip file will be created and uploaded to central: +This 1 zip will contain: +1. The aggregator pom (+ asc, md5 and sha1 files) +2. common, contains the pom, jar, javadoc and sources (all signed and with md5 and sha1 files) +3. subA, contains the pom, jar, javadoc and sources (all signed and with md5 and sha1 files) +4. subB, contains the pom, jar, javadoc and sources (all signed and with md5 and sha1 files) + +## Running only this test +```shell +mvn -Prun-its verify -Dinvoker.test=central-deploy-megabundle +``` + +## Running the test manually +```shell +# copy resources +mvn -Prun-its verify -Dinvoker.test=central-deploy-megabundle +cd target/it/central-deploy-bundles +CLASSPATH=$(find "$MAVEN_HOME/lib" -name "*.jar" | tr '\n' ':' | sed 's/:$//') +CLASSPATH=$CLASSPATH:$(./addDependencies.groovy) +groovy -cp $CLASSPATH -Dbasedir=$PWD setup.groovy & +mvn --settings ../../../src/it/settings.xml deploy +groovy -cp $CLASSPATH -Dbasedir=$PWD verify.groovy +``` \ No newline at end of file diff --git a/src/it/central-deploy-megabundle/addDependencies.groovy b/src/it/central-deploy-megabundle/addDependencies.groovy new file mode 100755 index 00000000..06fa39d7 --- /dev/null +++ b/src/it/central-deploy-megabundle/addDependencies.groovy @@ -0,0 +1,87 @@ +#!/usr/bin/env groovy +/* + * 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. + */ +// This script creates a classpath string based on the dependencies for the invoker plugin +// It is used when you want to change the groovy scripts and have more control from the command line +// i.e allows you to run each step manually. See readme.md for details. +import groovy.xml.XmlParser +import groovy.xml.XmlNodePrinter +import groovy.xml.StreamingMarkupBuilder + +// Paths +def realPom = new File("../../../pom.xml") +def tmpDir = File.createTempFile("tmp", "") +tmpDir.delete() +tmpDir.mkdirs() +def tmpPom = new File(tmpDir, "pom.xml") +def depsDir = new File(tmpDir, "deps") +depsDir.mkdirs() + +// Parse original POM without namespaces +def parser = new XmlParser(false, false) // disable namespaces +def pom = parser.parse(realPom) + +// Locate dependencies inside maven-invoker-plugin / run-its profile +def runItsProfile = pom.profiles.profile.find { it.id.text() == "run-its" } +def rawDeps = [] +if (runItsProfile) { + rawDeps = runItsProfile.depthFirst().findAll { + it.name() == "plugin" && it.artifactId.text() == "maven-invoker-plugin" + }*.dependencies*.dependency.flatten() +} + +// Build minimal POM +def newPom = { + mkp.xmlDeclaration() + project(xmlns: "http://maven.apache.org/POM/4.0.0", + 'xmlns:xsi': "http://www.w3.org/2001/XMLSchema-instance", + 'xsi:schemaLocation': "http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd") { + modelVersion("4.0.0") + groupId("temp") + artifactId("invoker-plugin-deps") + version("1.0-SNAPSHOT") + dependencies { + rawDeps.each { dep -> + dependency { + groupId(dep.groupId.text()) + artifactId(dep.artifactId.text()) + version(dep.version.text()) + if (dep.scope) scope(dep.scope.text()) + } + } + } + } +} + +// Write temp POM +tmpPom.text = new StreamingMarkupBuilder().bind(newPom).toString() + +//println "Downloading dependencies..." +def proc = ["mvn", "-q", "-f", tmpPom.absolutePath, + "dependency:copy-dependencies", + "-DoutputDirectory=${depsDir.absolutePath}", + "-DincludeScope=runtime"].execute() +proc.in.eachLine { println it } +proc.err.eachLine { System.err.println it } +proc.waitFor() + +// Build classpath +def jars = depsDir.listFiles().findAll { it.name.endsWith(".jar") } +def classpath = jars.collect { it.absolutePath }.join(":") +println classpath \ No newline at end of file diff --git a/src/it/central-deploy-megabundle/common/pom.xml b/src/it/central-deploy-megabundle/common/pom.xml new file mode 100644 index 00000000..321a88a9 --- /dev/null +++ b/src/it/central-deploy-megabundle/common/pom.xml @@ -0,0 +1,63 @@ + + + + + 4.0.0 + + se.alipsa.maven.example + publishing-example-parent + 1.0.0 + + publishing-example-common + Maven Central Publishing Example, Common + https://github.com/perNyfelt/maven-central-publishing-example + jar + + Maven Central Publishing Example, Common + + + Apache-2.0 + https://www.apache.org/licenses/LICENSE-2.0.txt + + + + + pnyfelt + Per Nyfelt + + + + https://github.com/perNyfelt/maven-central-publishing-example/tree/master + scm:git:https://github.com/perNyfelt/maven-central-publishing-example.git + scm:git:https://github.com/perNyfelt/maven-central-publishing-example.git + + + + + org.apache.maven.plugins + maven-javadoc-plugin + + + org.apache.maven.plugins + maven-source-plugin + + + + \ No newline at end of file diff --git a/src/it/central-deploy-megabundle/common/readme.md b/src/it/central-deploy-megabundle/common/readme.md new file mode 100644 index 00000000..32cc86d8 --- /dev/null +++ b/src/it/central-deploy-megabundle/common/readme.md @@ -0,0 +1,17 @@ + +# This is the common module for the multimodule project \ No newline at end of file diff --git a/src/it/central-deploy-megabundle/common/src/main/java/multimodule/common/Greeting.java b/src/it/central-deploy-megabundle/common/src/main/java/multimodule/common/Greeting.java new file mode 100644 index 00000000..0a9be567 --- /dev/null +++ b/src/it/central-deploy-megabundle/common/src/main/java/multimodule/common/Greeting.java @@ -0,0 +1,40 @@ +/* + * 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 multimodule.common; + +/** + * Common interface to define a greeting. + */ +public interface Greeting { + + /** + * Hello {name}. + * + * @param name the name to greet + * @return Hello {name} + */ + String greet(String name); + + /** + * Prints some info. + */ + default void info() { + System.out.println(this.getClass()); + } +} \ No newline at end of file diff --git a/src/it/central-deploy-megabundle/fakeSign.groovy b/src/it/central-deploy-megabundle/fakeSign.groovy new file mode 100644 index 00000000..b4320453 --- /dev/null +++ b/src/it/central-deploy-megabundle/fakeSign.groovy @@ -0,0 +1,86 @@ +/* + * 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. + */ +import org.apache.maven.model.Model +import org.apache.maven.model.io.xpp3.MavenXpp3Reader + +// Since we do not want to mess with external gpg config, we just create fake asc files +signFile(copyPom("pom.xml", "target")) +signJarFiles("target") + +def signJarFiles(String dir) { + def targetDir = new File(getTestDir(), dir) + targetDir.listFiles({ file -> file.name.endsWith(".jar") } as FileFilter).each { + signFile(it) + } +} + +static def signFile(File file) { + def ascFile = new File(file.getParentFile(), "${file.getName()}.asc") + println "Setup: creating signing file: $ascFile" + ascFile << "fake signed" +} + +def copyPom(String from, String toDir) { + File testDir = getTestDir() + def src = new File(testDir, from) + def pomInfo = readPomInfo(src) + def dstDir = new File(testDir, toDir) + dstDir.mkdirs() + def toFile = new File(dstDir, "$pomInfo.artifactId-${pomInfo.version}.pom") + toFile.text = src.text + return toFile +} + +static def readPomInfo(File pomFile) { + MavenXpp3Reader reader = new MavenXpp3Reader() + pomFile.withReader { r -> + Model model = reader.read(r) + + // Inherit groupId/version from parent if missing + if (!model.groupId && model.parent) { + model.groupId = model.parent.groupId + } + if (!model.version && model.parent) { + model.version = model.parent.version + } + + return [ + groupId : model.groupId, + artifactId : model.artifactId, + version : model.version + ] + } +} + +File getTestDir() { + if (binding.hasVariable('project')) { + // from gmavenplus + return binding.getVariable('project').basedir as File + } + def bd + if (binding.hasVariable('basedir')) { + // from the invoker plugin + bd = binding.getVariable('basedir') + } else { + // from command line (e.g. with -Dbasedir=$PWD) + bd = System.getProperty("basedir") + } + bd instanceof File ? bd : new File(bd) +} + diff --git a/src/it/central-deploy-megabundle/invoker.properties b/src/it/central-deploy-megabundle/invoker.properties new file mode 100644 index 00000000..1f3fd891 --- /dev/null +++ b/src/it/central-deploy-megabundle/invoker.properties @@ -0,0 +1,18 @@ +# 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. + +invoker.goals = deploy \ No newline at end of file diff --git a/src/it/central-deploy-megabundle/pom.xml b/src/it/central-deploy-megabundle/pom.xml new file mode 100644 index 00000000..dca28050 --- /dev/null +++ b/src/it/central-deploy-megabundle/pom.xml @@ -0,0 +1,161 @@ + + + + 4.0.0 + se.alipsa.maven.example + publishing-example-parent + 1.0.0 + + Maven Central Publishing Example, Aggregator + https://github.com/perNyfelt/maven-central-publishing-example + pom + + + common + subA + + + Maven Central Publishing Example, Aggregator + + + Apache-2.0 + https://www.apache.org/licenses/LICENSE-2.0.txt + + + + + pnyfelt + Per Nyfelt + + + + https://github.com/perNyfelt/maven-central-publishing-example/tree/master + scm:git:https://github.com/perNyfelt/maven-central-publishing-example.git + scm:git:https://github.com/perNyfelt/maven-central-publishing-example.git + + + UTF-8 + UTF-8 + 1.8 + 1.8 + + + + + central + Central Repository + http://localhost:8088/api/v1 + + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + @mavenCompilerPluginVersion@ + + ${maven.compiler.source} + ${maven.compiler.target} + + -Xlint:-options + + + + + org.apache.maven.plugins + maven-deploy-plugin + @project.version@ + + true + true + false + + + + org.apache.maven.plugins + maven-javadoc-plugin + @mavenJavadocPluginVersion@ + + + attach-javadocs + + jar + + + + + + org.apache.maven.plugins + maven-source-plugin + @mavenSourcePluginVersion@ + + + attach-sources + + jar + + + + + + + + + + org.codehaus.gmavenplus + gmavenplus-plugin + 1.13.1 + + + simulate-signing + verify + + execute + + + + fakeSign.groovy + + + + + + + org.apache.groovy + groovy + 4.0.27 + + + org.apache.groovy + groovy-ant + 4.0.27 + + + + + org.apache.maven.plugins + maven-deploy-plugin + + + + \ No newline at end of file diff --git a/src/it/central-deploy-megabundle/setup.groovy b/src/it/central-deploy-megabundle/setup.groovy new file mode 100644 index 00000000..a8ffbdc8 --- /dev/null +++ b/src/it/central-deploy-megabundle/setup.groovy @@ -0,0 +1,189 @@ +/* + * 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. + */ +import org.eclipse.jetty.server.Server +import org.eclipse.jetty.servlet.* +import javax.servlet.http.* +import javax.servlet.* +import java.util.concurrent.ConcurrentHashMap +import org.apache.commons.fileupload.servlet.ServletFileUpload +import org.apache.commons.io.IOUtils +import com.fasterxml.jackson.databind.ObjectMapper + +def port = 8088 +def server = new Server(port) +def context = new ServletContextHandler(ServletContextHandler.SESSIONS) +context.setContextPath("/") + +def bundles = new ConcurrentHashMap() +def mapper = new ObjectMapper() + +// /api/v1 - Upload endpoint +context.addServlet(new ServletHolder(new HttpServlet() { + protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { + try { + if (!authHeader().equals(req.getHeader("Authorization"))) { + resp.setStatus(401) + println "Bearer token not recognized, should be based on testUser:testPwd" + return + } + + if (!ServletFileUpload.isMultipartContent(req)) { + resp.setStatus(400) + return + } + + def multipartConfig = new MultipartConfigElement(System.getProperty("java.io.tmpdir")) + req.setAttribute("org.eclipse.jetty.multipartConfig", multipartConfig) + + def publishingType = req.getParameter("publishingType") + def name = req.getParameter("name") + def part = req.getPart("bundle") + def zipData = IOUtils.toByteArray(part.inputStream) + if (zipData == null || zipData.length == 0) { + resp.setStatus(400) + resp.setContentType("application/json") + resp.writer.write("bundle contains no data") + return + } + def deploymentId = UUID.randomUUID().toString() + + bundles.put(deploymentId, zipData) + println("Received bundle " + name) + + resp.setContentType("text/plain") + resp.writer.write(deploymentId) + + } catch (Exception e) { + // Log stack trace to console + e.printStackTrace() + + // Respond with HTTP 500 and JSON error details + resp.setStatus(500) + resp.setContentType("application/json") + def errorResponse = [ + error: e.getClass().name, + message: e.message, + stackTrace: e.stackTrace.collect { it.toString() } + ] + resp.writer.write(mapper.writeValueAsString(errorResponse)) + } + } +}), "/api/v1/publisher/upload") + +// /publisher/status - Status check +context.addServlet(new ServletHolder(new HttpServlet() { + protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { + try { + if (!authHeader().equals(req.getHeader("Authorization"))) { + resp.setStatus(401) + println "Bearer token not recognized, should be based on testUser:testPwd" + return + } + + def deploymentId = req.getParameter("id") + def data = bundles.get(deploymentId) + + if (data == null) { + resp.writer.write("Deployment $deploymentId not found") + resp.setStatus(404) + return + } + + resp.setContentType("application/json") + Map deployments = new HashMap<>() + deployments.put(deploymentId, [ + deploymentId: deploymentId, + deploymentState: "VALIDATED", + purls: ["pkg:maven/com.sonatype.central.example/example_java_project@0.0.7"] + ]) + resp.writer.write(mapper.writeValueAsString(deployments)) + } catch (Exception e) { + // Log stack trace to console + e.printStackTrace() + + // Respond with HTTP 500 and JSON error details + resp.setStatus(500) + resp.setContentType("application/json") + def errorResponse = [ + error: e.getClass().name, + message: e.message, + stackTrace: e.stackTrace.collect { it.toString() } + ] + resp.writer.write(mapper.writeValueAsString(errorResponse)) + } + } +}), "/api/v1/publisher/status") + +// /getBundleIds - get a json list of all uploaded bundles +context.addServlet(new ServletHolder(new HttpServlet() { + protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { + resp.setContentType("application/json") + resp.writer.write(mapper.writeValueAsString(bundles.keys())) + } +}), "/getBundleIds") + +// /getBundle - Retrieve the uploaded bundle by deploymentId +context.addServlet(new ServletHolder(new HttpServlet() { + protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { + def id = req.getParameter("deploymentId") + def zip = bundles.get(id) + if (zip == null) { + resp.setStatus(404) + return + } + + resp.setContentType("application/zip") + resp.setHeader("Content-Disposition", "attachment; filename=\"${id}.zip\"") + resp.outputStream.write(zip) + } +}), "/getBundle") + +// /shutdown - Shut down the server gracefully +context.addServlet(new ServletHolder(new HttpServlet() { + protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { + resp.writer.write("Shutting down...") + new Thread({ + Thread.sleep(1000) + server.stop() + }).start() + } +}), "/shutdown") + +context.addServlet(new ServletHolder(new HttpServlet() { + protected void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { + // Log or print the unmatched request URI + println("Unhandled request to: " + req.getRequestURI()) + + resp.setStatus(404) + resp.setContentType("application/json") + resp.writer.write(mapper.writeValueAsString([ + error: "Unknown endpoint", + path: req.getRequestURI() + ])) + } +}), "/*") + +// Must match the user and pwd in settings.xml for the central server +private String authHeader() { + return "Bearer " + Base64.getEncoder().encodeToString(("testUser:testPwd").getBytes()); +} + +server.setHandler(context) +server.start() + diff --git a/src/it/central-deploy-megabundle/subA/pom.xml b/src/it/central-deploy-megabundle/subA/pom.xml new file mode 100644 index 00000000..04244dbe --- /dev/null +++ b/src/it/central-deploy-megabundle/subA/pom.xml @@ -0,0 +1,71 @@ + + + + + 4.0.0 + + se.alipsa.maven.example + publishing-example-parent + 1.0.0 + + publishing-example-subA + Maven Central Publishing Example, SubA + https://github.com/perNyfelt/maven-central-publishing-example + jar + + Maven Central Publishing Example, SubA + + + Apache-2.0 + https://www.apache.org/licenses/LICENSE-2.0.txt + + + + + pnyfelt + Per Nyfelt + + + + https://github.com/perNyfelt/maven-central-publishing-example/tree/master + scm:git:https://github.com/perNyfelt/maven-central-publishing-example.git + scm:git:https://github.com/perNyfelt/maven-central-publishing-example.git + + + + se.alipsa.maven.example + publishing-example-common + 1.0.0 + + + + + + + org.apache.maven.plugins + maven-javadoc-plugin + + + org.apache.maven.plugins + maven-source-plugin + + + + \ No newline at end of file diff --git a/src/it/central-deploy-megabundle/subA/readme.md b/src/it/central-deploy-megabundle/subA/readme.md new file mode 100644 index 00000000..31f35ed8 --- /dev/null +++ b/src/it/central-deploy-megabundle/subA/readme.md @@ -0,0 +1,17 @@ + +# The subA sub projects of the multmodule project \ No newline at end of file diff --git a/src/it/central-deploy-megabundle/subA/src/main/java/multimodule/suba/Hello.java b/src/it/central-deploy-megabundle/subA/src/main/java/multimodule/suba/Hello.java new file mode 100644 index 00000000..7593984d --- /dev/null +++ b/src/it/central-deploy-megabundle/subA/src/main/java/multimodule/suba/Hello.java @@ -0,0 +1,37 @@ +/* + * 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 multimodule.suba; + +import multimodule.common.Greeting; + +/** + * This is the Greeting implementation in subA + */ +public class Hello implements Greeting { + + /** + * Default constructor. + */ + public Hello() {} + + @Override + public String greet(String name) { + return "Hello $name from suba"; + } +} diff --git a/src/it/central-deploy-megabundle/verify.groovy b/src/it/central-deploy-megabundle/verify.groovy new file mode 100644 index 00000000..e9e07266 --- /dev/null +++ b/src/it/central-deploy-megabundle/verify.groovy @@ -0,0 +1,122 @@ +/* + * 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. + */ +import groovy.json.JsonSlurper + +import java.nio.file.Files +import java.util.zip.ZipFile + +String baseUrl = "http://localhost:8088" + +URLConnection conn = new URI("$baseUrl/getBundleIds").toURL().openConnection() +conn.setRequestMethod("GET") +conn.connect() +def json = conn.inputStream.text +def ids = new JsonSlurper().parseText(json) +println "verify: Received bundle IDs: $ids" + +// Check that exactly one bundle was uploaded +assert ids.size() == 1 : "Expected exactly one bundle, but got ${ids.size()}" +def deploymentId = ids[0] + +// download and check the zip content +conn = new URI("$baseUrl/getBundle?deploymentId=$deploymentId").toURL().openConnection() +conn.setRequestMethod("GET") +conn.connect() +def zip = Files.createTempFile("bundle", ".zip") +def megaZip = zip.toFile() +if (megaZip.exists()) { + megaZip.delete() +} +Files.copy(conn.inputStream, zip) + +String groupId = "se.alipsa.maven.example" +def expectedAggregatorEntries = { String artifactId, String version -> + String basePath = "${groupId.replace('.', '/')}/${artifactId}/${version}/" + + String artifactPath = basePath + artifactId + '-' + version + [ + artifactPath + '.pom', + artifactPath +'.pom.asc', + artifactPath +'.pom.md5', + artifactPath +'.pom.sha1', + artifactPath +'.pom.sha256' + ] +} + +def expectedFullEntries = {String artifactId, String version -> + String basePath = "${groupId.replace('.', '/')}/${artifactId}/${version}/" + String artifactPath = basePath + artifactId + '-' + version + [ + artifactPath + '.jar', + artifactPath + '.jar.asc', + artifactPath + '.jar.md5', + artifactPath + '.jar.sha1', + artifactPath + '.jar.sha256', + artifactPath + '-sources.jar', + artifactPath + '-sources.jar.asc', + artifactPath + '-sources.jar.md5', + artifactPath + '-sources.jar.sha1', + artifactPath + '-sources.jar.sha256', + artifactPath + '-javadoc.jar', + artifactPath + '-javadoc.jar.asc', + artifactPath + '-javadoc.jar.md5', + artifactPath + '-javadoc.jar.sha1', + artifactPath + '-javadoc.jar.sha256' + ] + expectedAggregatorEntries(artifactId, version) +} + +checkZipContent(megaZip, "publishing-example-parent", "1.0.0", expectedAggregatorEntries) +checkZipContent(megaZip, "publishing-example-common", "1.0.0", expectedFullEntries) +checkZipContent(megaZip, "publishing-example-subA", "1.0.0", expectedFullEntries) +List aggregatorEntries = expectedAggregatorEntries("publishing-example-parent", "1.0.0") +List commonEntries = expectedFullEntries("publishing-example-common", "1.0.0") +List subAEntries = expectedFullEntries("publishing-example-subA", "1.0.0") + +def actualEntries +try (ZipFile zipFile = new ZipFile(megaZip)) { + actualEntries = zipFile.entries().collect { it.name } +} +int expectedEntries = aggregatorEntries.size() + commonEntries.size() + subAEntries.size() +assert expectedEntries == actualEntries.size() : "Mismatch in number of entries in ZIP, expected $expectedEntries but was ${actualEntries.size()}" + +static def checkZipContent(File zipFile, String artifactId, String version, Closure expectedMethod) { + println "Checking content of $zipFile" + List expectedEntries = expectedMethod(artifactId, version) + List actualEntries + try (ZipFile zip = new ZipFile(zipFile)) { + actualEntries = zip.entries().collect { it.name } + } + expectedEntries.each { + //println " - checking $it" + assert actualEntries.contains(it) : "Expected entry not found in $zipFile.name: $it" + } + return actualEntries +} +// Cleanup +megaZip.deleteOnExit() +// Shut down the server +try { + conn = new URI("$baseUrl/shutdown").toURL().openConnection() + conn.setRequestMethod("POST") + conn.connect() + println conn.inputStream.text + Thread.sleep(1000) +} catch (Exception e) { + println "Shutdown failed: ${e.message}" +} \ No newline at end of file diff --git a/src/it/central-deploy-simplebundle/README.md b/src/it/central-deploy-simplebundle/README.md new file mode 100644 index 00000000..51e16ebd --- /dev/null +++ b/src/it/central-deploy-simplebundle/README.md @@ -0,0 +1,43 @@ + +# central-deploy-simplebundle + +This is a basic example showing deployment to maven central +using the new central publishing rest api. + + +The project consist of +- a main pom that produces a jar, javadoc and sources + +When deploying the whole project, 1 zip file will be created and uploaded to central. + +## Running only this test +```shell +mvn -Prun-its verify -Dinvoker.test=central-deploy-simplebundle +``` + +## Running the test manually +```shell +# copy resources +mvn -Prun-its verify -Dinvoker.test=central-deploy-simplebundle +cd target/it/central-deploy-bundles +CLASSPATH=$(find "$MAVEN_HOME/lib" -name "*.jar" | tr '\n' ':' | sed 's/:$//') +CLASSPATH=$CLASSPATH:$(./addDependencies.groovy) +groovy -cp $CLASSPATH -Dbasedir=$PWD setup.groovy & +mvn --settings ../../../src/it/settings.xml deploy +groovy -cp $CLASSPATH -Dbasedir=$PWD verify.groovy +``` \ No newline at end of file diff --git a/src/it/central-deploy-simplebundle/addDependencies.groovy b/src/it/central-deploy-simplebundle/addDependencies.groovy new file mode 100755 index 00000000..06fa39d7 --- /dev/null +++ b/src/it/central-deploy-simplebundle/addDependencies.groovy @@ -0,0 +1,87 @@ +#!/usr/bin/env groovy +/* + * 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. + */ +// This script creates a classpath string based on the dependencies for the invoker plugin +// It is used when you want to change the groovy scripts and have more control from the command line +// i.e allows you to run each step manually. See readme.md for details. +import groovy.xml.XmlParser +import groovy.xml.XmlNodePrinter +import groovy.xml.StreamingMarkupBuilder + +// Paths +def realPom = new File("../../../pom.xml") +def tmpDir = File.createTempFile("tmp", "") +tmpDir.delete() +tmpDir.mkdirs() +def tmpPom = new File(tmpDir, "pom.xml") +def depsDir = new File(tmpDir, "deps") +depsDir.mkdirs() + +// Parse original POM without namespaces +def parser = new XmlParser(false, false) // disable namespaces +def pom = parser.parse(realPom) + +// Locate dependencies inside maven-invoker-plugin / run-its profile +def runItsProfile = pom.profiles.profile.find { it.id.text() == "run-its" } +def rawDeps = [] +if (runItsProfile) { + rawDeps = runItsProfile.depthFirst().findAll { + it.name() == "plugin" && it.artifactId.text() == "maven-invoker-plugin" + }*.dependencies*.dependency.flatten() +} + +// Build minimal POM +def newPom = { + mkp.xmlDeclaration() + project(xmlns: "http://maven.apache.org/POM/4.0.0", + 'xmlns:xsi': "http://www.w3.org/2001/XMLSchema-instance", + 'xsi:schemaLocation': "http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd") { + modelVersion("4.0.0") + groupId("temp") + artifactId("invoker-plugin-deps") + version("1.0-SNAPSHOT") + dependencies { + rawDeps.each { dep -> + dependency { + groupId(dep.groupId.text()) + artifactId(dep.artifactId.text()) + version(dep.version.text()) + if (dep.scope) scope(dep.scope.text()) + } + } + } + } +} + +// Write temp POM +tmpPom.text = new StreamingMarkupBuilder().bind(newPom).toString() + +//println "Downloading dependencies..." +def proc = ["mvn", "-q", "-f", tmpPom.absolutePath, + "dependency:copy-dependencies", + "-DoutputDirectory=${depsDir.absolutePath}", + "-DincludeScope=runtime"].execute() +proc.in.eachLine { println it } +proc.err.eachLine { System.err.println it } +proc.waitFor() + +// Build classpath +def jars = depsDir.listFiles().findAll { it.name.endsWith(".jar") } +def classpath = jars.collect { it.absolutePath }.join(":") +println classpath \ No newline at end of file diff --git a/src/it/central-deploy-simplebundle/fakeSign.groovy b/src/it/central-deploy-simplebundle/fakeSign.groovy new file mode 100644 index 00000000..b4320453 --- /dev/null +++ b/src/it/central-deploy-simplebundle/fakeSign.groovy @@ -0,0 +1,86 @@ +/* + * 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. + */ +import org.apache.maven.model.Model +import org.apache.maven.model.io.xpp3.MavenXpp3Reader + +// Since we do not want to mess with external gpg config, we just create fake asc files +signFile(copyPom("pom.xml", "target")) +signJarFiles("target") + +def signJarFiles(String dir) { + def targetDir = new File(getTestDir(), dir) + targetDir.listFiles({ file -> file.name.endsWith(".jar") } as FileFilter).each { + signFile(it) + } +} + +static def signFile(File file) { + def ascFile = new File(file.getParentFile(), "${file.getName()}.asc") + println "Setup: creating signing file: $ascFile" + ascFile << "fake signed" +} + +def copyPom(String from, String toDir) { + File testDir = getTestDir() + def src = new File(testDir, from) + def pomInfo = readPomInfo(src) + def dstDir = new File(testDir, toDir) + dstDir.mkdirs() + def toFile = new File(dstDir, "$pomInfo.artifactId-${pomInfo.version}.pom") + toFile.text = src.text + return toFile +} + +static def readPomInfo(File pomFile) { + MavenXpp3Reader reader = new MavenXpp3Reader() + pomFile.withReader { r -> + Model model = reader.read(r) + + // Inherit groupId/version from parent if missing + if (!model.groupId && model.parent) { + model.groupId = model.parent.groupId + } + if (!model.version && model.parent) { + model.version = model.parent.version + } + + return [ + groupId : model.groupId, + artifactId : model.artifactId, + version : model.version + ] + } +} + +File getTestDir() { + if (binding.hasVariable('project')) { + // from gmavenplus + return binding.getVariable('project').basedir as File + } + def bd + if (binding.hasVariable('basedir')) { + // from the invoker plugin + bd = binding.getVariable('basedir') + } else { + // from command line (e.g. with -Dbasedir=$PWD) + bd = System.getProperty("basedir") + } + bd instanceof File ? bd : new File(bd) +} + diff --git a/src/it/central-deploy-simplebundle/invoker.properties b/src/it/central-deploy-simplebundle/invoker.properties new file mode 100644 index 00000000..1f3fd891 --- /dev/null +++ b/src/it/central-deploy-simplebundle/invoker.properties @@ -0,0 +1,18 @@ +# 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. + +invoker.goals = deploy \ No newline at end of file diff --git a/src/it/central-deploy-simplebundle/pom.xml b/src/it/central-deploy-simplebundle/pom.xml new file mode 100644 index 00000000..4adbbce4 --- /dev/null +++ b/src/it/central-deploy-simplebundle/pom.xml @@ -0,0 +1,164 @@ + + + + 4.0.0 + se.alipsa.maven.example + publishing-example-simple + 1.0.0-SNAPSHOT + + Maven Central Publishing Example, Simple + https://github.com/perNyfelt/maven-central-publishing-example + jar + + Maven Central Publishing Example, simple + + + Apache-2.0 + https://www.apache.org/licenses/LICENSE-2.0.txt + + + + + pnyfelt + Per Nyfelt + + + + https://github.com/perNyfelt/maven-central-publishing-example/tree/master + scm:git:https://github.com/perNyfelt/maven-central-publishing-example.git + scm:git:https://github.com/perNyfelt/maven-central-publishing-example.git + + + UTF-8 + UTF-8 + 1.8 + 1.8 + + + + + central + Central Snapshot Repository + http://localhost:8088/api/v1 + + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + @mavenCompilerPluginVersion@ + + ${maven.compiler.source} + ${maven.compiler.target} + + -Xlint:-options + + + + + org.apache.maven.plugins + maven-deploy-plugin + @project.version@ + + true + true + false + + + + org.apache.maven.plugins + maven-javadoc-plugin + @mavenJavadocPluginVersion@ + + + attach-javadocs + + jar + + + + + + org.apache.maven.plugins + maven-source-plugin + @mavenSourcePluginVersion@ + + + attach-sources + + jar + + + + + + + + + org.apache.maven.plugins + maven-javadoc-plugin + + + org.apache.maven.plugins + maven-source-plugin + + + + org.codehaus.gmavenplus + gmavenplus-plugin + 1.13.1 + + + simulate-signing + verify + + execute + + + + fakeSign.groovy + + + + + + + org.apache.groovy + groovy + 4.0.27 + + + org.apache.groovy + groovy-ant + 4.0.27 + + + + + org.apache.maven.plugins + maven-deploy-plugin + + + + \ No newline at end of file diff --git a/src/it/central-deploy-simplebundle/setup.groovy b/src/it/central-deploy-simplebundle/setup.groovy new file mode 100644 index 00000000..a8ffbdc8 --- /dev/null +++ b/src/it/central-deploy-simplebundle/setup.groovy @@ -0,0 +1,189 @@ +/* + * 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. + */ +import org.eclipse.jetty.server.Server +import org.eclipse.jetty.servlet.* +import javax.servlet.http.* +import javax.servlet.* +import java.util.concurrent.ConcurrentHashMap +import org.apache.commons.fileupload.servlet.ServletFileUpload +import org.apache.commons.io.IOUtils +import com.fasterxml.jackson.databind.ObjectMapper + +def port = 8088 +def server = new Server(port) +def context = new ServletContextHandler(ServletContextHandler.SESSIONS) +context.setContextPath("/") + +def bundles = new ConcurrentHashMap() +def mapper = new ObjectMapper() + +// /api/v1 - Upload endpoint +context.addServlet(new ServletHolder(new HttpServlet() { + protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { + try { + if (!authHeader().equals(req.getHeader("Authorization"))) { + resp.setStatus(401) + println "Bearer token not recognized, should be based on testUser:testPwd" + return + } + + if (!ServletFileUpload.isMultipartContent(req)) { + resp.setStatus(400) + return + } + + def multipartConfig = new MultipartConfigElement(System.getProperty("java.io.tmpdir")) + req.setAttribute("org.eclipse.jetty.multipartConfig", multipartConfig) + + def publishingType = req.getParameter("publishingType") + def name = req.getParameter("name") + def part = req.getPart("bundle") + def zipData = IOUtils.toByteArray(part.inputStream) + if (zipData == null || zipData.length == 0) { + resp.setStatus(400) + resp.setContentType("application/json") + resp.writer.write("bundle contains no data") + return + } + def deploymentId = UUID.randomUUID().toString() + + bundles.put(deploymentId, zipData) + println("Received bundle " + name) + + resp.setContentType("text/plain") + resp.writer.write(deploymentId) + + } catch (Exception e) { + // Log stack trace to console + e.printStackTrace() + + // Respond with HTTP 500 and JSON error details + resp.setStatus(500) + resp.setContentType("application/json") + def errorResponse = [ + error: e.getClass().name, + message: e.message, + stackTrace: e.stackTrace.collect { it.toString() } + ] + resp.writer.write(mapper.writeValueAsString(errorResponse)) + } + } +}), "/api/v1/publisher/upload") + +// /publisher/status - Status check +context.addServlet(new ServletHolder(new HttpServlet() { + protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { + try { + if (!authHeader().equals(req.getHeader("Authorization"))) { + resp.setStatus(401) + println "Bearer token not recognized, should be based on testUser:testPwd" + return + } + + def deploymentId = req.getParameter("id") + def data = bundles.get(deploymentId) + + if (data == null) { + resp.writer.write("Deployment $deploymentId not found") + resp.setStatus(404) + return + } + + resp.setContentType("application/json") + Map deployments = new HashMap<>() + deployments.put(deploymentId, [ + deploymentId: deploymentId, + deploymentState: "VALIDATED", + purls: ["pkg:maven/com.sonatype.central.example/example_java_project@0.0.7"] + ]) + resp.writer.write(mapper.writeValueAsString(deployments)) + } catch (Exception e) { + // Log stack trace to console + e.printStackTrace() + + // Respond with HTTP 500 and JSON error details + resp.setStatus(500) + resp.setContentType("application/json") + def errorResponse = [ + error: e.getClass().name, + message: e.message, + stackTrace: e.stackTrace.collect { it.toString() } + ] + resp.writer.write(mapper.writeValueAsString(errorResponse)) + } + } +}), "/api/v1/publisher/status") + +// /getBundleIds - get a json list of all uploaded bundles +context.addServlet(new ServletHolder(new HttpServlet() { + protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { + resp.setContentType("application/json") + resp.writer.write(mapper.writeValueAsString(bundles.keys())) + } +}), "/getBundleIds") + +// /getBundle - Retrieve the uploaded bundle by deploymentId +context.addServlet(new ServletHolder(new HttpServlet() { + protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { + def id = req.getParameter("deploymentId") + def zip = bundles.get(id) + if (zip == null) { + resp.setStatus(404) + return + } + + resp.setContentType("application/zip") + resp.setHeader("Content-Disposition", "attachment; filename=\"${id}.zip\"") + resp.outputStream.write(zip) + } +}), "/getBundle") + +// /shutdown - Shut down the server gracefully +context.addServlet(new ServletHolder(new HttpServlet() { + protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { + resp.writer.write("Shutting down...") + new Thread({ + Thread.sleep(1000) + server.stop() + }).start() + } +}), "/shutdown") + +context.addServlet(new ServletHolder(new HttpServlet() { + protected void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { + // Log or print the unmatched request URI + println("Unhandled request to: " + req.getRequestURI()) + + resp.setStatus(404) + resp.setContentType("application/json") + resp.writer.write(mapper.writeValueAsString([ + error: "Unknown endpoint", + path: req.getRequestURI() + ])) + } +}), "/*") + +// Must match the user and pwd in settings.xml for the central server +private String authHeader() { + return "Bearer " + Base64.getEncoder().encodeToString(("testUser:testPwd").getBytes()); +} + +server.setHandler(context) +server.start() + diff --git a/src/it/central-deploy-simplebundle/src/main/java/simple/common/Greeting.java b/src/it/central-deploy-simplebundle/src/main/java/simple/common/Greeting.java new file mode 100644 index 00000000..1700671b --- /dev/null +++ b/src/it/central-deploy-simplebundle/src/main/java/simple/common/Greeting.java @@ -0,0 +1,40 @@ +/* + * 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 simple.common; + +/** + * Common interface to define a greeting. + */ +public interface Greeting { + + /** + * Hello {name}. + * + * @param name the name to greet + * @return Hello {name} + */ + String greet(String name); + + /** + * Prints some info. + */ + default void info() { + System.out.println(this.getClass()); + } +} \ No newline at end of file diff --git a/src/it/central-deploy-simplebundle/src/main/java/simple/suba/Hello.java b/src/it/central-deploy-simplebundle/src/main/java/simple/suba/Hello.java new file mode 100644 index 00000000..78df288a --- /dev/null +++ b/src/it/central-deploy-simplebundle/src/main/java/simple/suba/Hello.java @@ -0,0 +1,37 @@ +/* + * 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 simple.suba; + +import simple.common.Greeting; + +/** + * This is the Greeting implementation in subA + */ +public class Hello implements Greeting { + + /** + * Default constructor. + */ + public Hello() {} + + @Override + public String greet(String name) { + return "Hello $name from suba"; + } +} diff --git a/src/it/central-deploy-simplebundle/verify.groovy b/src/it/central-deploy-simplebundle/verify.groovy new file mode 100644 index 00000000..dd38a8ef --- /dev/null +++ b/src/it/central-deploy-simplebundle/verify.groovy @@ -0,0 +1,110 @@ +/* + * 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. + */ +import groovy.json.JsonSlurper + +import java.nio.file.Files +import java.util.zip.ZipFile + +String baseUrl = "http://localhost:8088" + +URLConnection conn = new URI("$baseUrl/getBundleIds").toURL().openConnection() +conn.setRequestMethod("GET") +conn.connect() +def json = conn.inputStream.text +def ids = new JsonSlurper().parseText(json) +println "verify: Received bundle IDs: $ids" + +// Check that exactly one bundle was uploaded +assert ids.size() == 1 : "Expected exactly one bundle, but got ${ids.size()}" +def deploymentId = ids[0] + +// download and check the zip content +conn = new URI("$baseUrl/getBundle?deploymentId=$deploymentId").toURL().openConnection() +conn.setRequestMethod("GET") +conn.connect() +def zip = Files.createTempFile("bundle", ".zip") +def megaZip = zip.toFile() +if (megaZip.exists()) { + megaZip.delete() +} +Files.copy(conn.inputStream, zip) + +String groupId = "se.alipsa.maven.example" + +def expectedEntries = {String artifactId, String version -> + String basePath = "${groupId.replace('.', '/')}/${artifactId}/${version}/" + String artifactPath = basePath + artifactId + '-' + version + [ + artifactPath + '.pom', + artifactPath +'.pom.asc', + artifactPath +'.pom.md5', + artifactPath +'.pom.sha1', + artifactPath +'.pom.sha256', + artifactPath + '.jar', + artifactPath + '.jar.asc', + artifactPath + '.jar.md5', + artifactPath + '.jar.sha1', + artifactPath + '.jar.sha256', + artifactPath + '-sources.jar', + artifactPath + '-sources.jar.asc', + artifactPath + '-sources.jar.md5', + artifactPath + '-sources.jar.sha1', + artifactPath + '-sources.jar.sha256', + artifactPath + '-javadoc.jar', + artifactPath + '-javadoc.jar.asc', + artifactPath + '-javadoc.jar.md5', + artifactPath + '-javadoc.jar.sha1', + artifactPath + '-javadoc.jar.sha256' + ] +} + +checkZipContent(megaZip, "publishing-example-simple", "1.0.0-SNAPSHOT", expectedEntries) +List entries = expectedEntries("publishing-example-simple", "1.0.0-SNAPSHOT") + +def actualEntries +try (ZipFile zipFile = new ZipFile(megaZip)) { + actualEntries = zipFile.entries().collect { it.name } +} +assert entries.size() == actualEntries.size() : "Mismatch in number of entries in ZIP, expected $expectedEntries but was ${actualEntries.size()}" + +static def checkZipContent(File zipFile, String artifactId, String version, Closure expectedMethod) { + println "Checking content of $zipFile" + List expectedEntries = expectedMethod(artifactId, version) + List actualEntries + try (ZipFile zip = new ZipFile(zipFile)) { + actualEntries = zip.entries().collect { it.name } + } + expectedEntries.each { + //println " - checking $it" + assert actualEntries.contains(it) : "Expected entry not found in $zipFile.name: $it" + } + return actualEntries +} +// Cleanup +megaZip.deleteOnExit() +// Shut down the server +try { + conn = new URI("$baseUrl/shutdown").toURL().openConnection() + conn.setRequestMethod("POST") + conn.connect() + println conn.inputStream.text + Thread.sleep(1000) +} catch (Exception e) { + println "Shutdown failed: ${e.message}" +} \ No newline at end of file diff --git a/src/it/central-zip-bundles/README.md b/src/it/central-zip-bundles/README.md new file mode 100644 index 00000000..3f10e5e6 --- /dev/null +++ b/src/it/central-zip-bundles/README.md @@ -0,0 +1,68 @@ + +# central-zip-bundles + +This is a multimodule example showing the creation of the zip bundle (for deployment to maven central) +using the new central publishing rest api. + +The project consist of +- an aggregator +- a common sub module +- two sub modules that each depends on the common submodule + +Each module (including the main aggregator) will be deployed separately. +I.e. when deploying the whole project, 4 zip files will be created and uploaded to central: +1. The aggregator pom (+ asc, md5 and sha1 files) +2. common, contains the pom, jar, javadoc and sources (all signed and with md5 and sha1 files) +3. subA, contains the pom, jar, javadoc and sources (all signed and with md5 and sha1 files) +4. subB, contains the pom, jar, javadoc and sources (all signed and with md5 and sha1 files) + +Note, normally we would have the gpg plugin configured in the build, e.g: +```xml + + org.apache.maven.plugins + maven-gpg-plugin + 3.2.8 + + + sign-artifacts + verify + + sign + + + + +``` +But this requires some external setup so we just create fake asc files in this test using a +a groovy script. We cannot do it in setup.groovy as the invoker plugin uses true +config, so instead we mimic what the sign plugin would do in another groovy script (fakeSign.groovy) +that we call from the pom (and hence it is part of the build, which is also closer to reality). + +## Running only this test +```shell +mvn -Prun-its verify -Dinvoker.test=central-zip-bundles +``` + +## Running the test manually +```shell +CLASSPATH=$(find "$MAVEN_HOME/lib" -name "*.jar" | tr '\n' ':' | sed 's/:$//') +mvn deploy +groovy -cp $CLASSPATH -Dbasedir=$PWD verify.groovy +``` + + diff --git a/src/it/central-zip-bundles/common/pom.xml b/src/it/central-zip-bundles/common/pom.xml new file mode 100644 index 00000000..f3f4316b --- /dev/null +++ b/src/it/central-zip-bundles/common/pom.xml @@ -0,0 +1,63 @@ + + + + + 4.0.0 + + se.alipsa.maven.example + publishing-example-parent + 1.0.0 + + publishing-example-common + Maven Central Publishing Example, Common + https://github.com/perNyfelt/maven-central-publishing-example + jar + + Maven Central Publishing Example, Common + + + Apache-2.0 + https://www.apache.org/licenses/LICENSE-2.0.txt + + + + + pnyfelt + Per Nyfelt + + + + https://github.com/perNyfelt/maven-central-publishing-example/tree/master + scm:git:https://github.com/perNyfelt/maven-central-publishing-example.git + scm:git:https://github.com/perNyfelt/maven-central-publishing-example.git + + + + + org.apache.maven.plugins + maven-source-plugin + + + org.apache.maven.plugins + maven-javadoc-plugin + + + + \ No newline at end of file diff --git a/src/it/central-zip-bundles/common/readme.md b/src/it/central-zip-bundles/common/readme.md new file mode 100644 index 00000000..32cc86d8 --- /dev/null +++ b/src/it/central-zip-bundles/common/readme.md @@ -0,0 +1,17 @@ + +# This is the common module for the multimodule project \ No newline at end of file diff --git a/src/it/central-zip-bundles/common/src/main/java/multimodule/common/Greeting.java b/src/it/central-zip-bundles/common/src/main/java/multimodule/common/Greeting.java new file mode 100644 index 00000000..0a9be567 --- /dev/null +++ b/src/it/central-zip-bundles/common/src/main/java/multimodule/common/Greeting.java @@ -0,0 +1,40 @@ +/* + * 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 multimodule.common; + +/** + * Common interface to define a greeting. + */ +public interface Greeting { + + /** + * Hello {name}. + * + * @param name the name to greet + * @return Hello {name} + */ + String greet(String name); + + /** + * Prints some info. + */ + default void info() { + System.out.println(this.getClass()); + } +} \ No newline at end of file diff --git a/src/it/central-zip-bundles/fakeSign.groovy b/src/it/central-zip-bundles/fakeSign.groovy new file mode 100644 index 00000000..b4320453 --- /dev/null +++ b/src/it/central-zip-bundles/fakeSign.groovy @@ -0,0 +1,86 @@ +/* + * 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. + */ +import org.apache.maven.model.Model +import org.apache.maven.model.io.xpp3.MavenXpp3Reader + +// Since we do not want to mess with external gpg config, we just create fake asc files +signFile(copyPom("pom.xml", "target")) +signJarFiles("target") + +def signJarFiles(String dir) { + def targetDir = new File(getTestDir(), dir) + targetDir.listFiles({ file -> file.name.endsWith(".jar") } as FileFilter).each { + signFile(it) + } +} + +static def signFile(File file) { + def ascFile = new File(file.getParentFile(), "${file.getName()}.asc") + println "Setup: creating signing file: $ascFile" + ascFile << "fake signed" +} + +def copyPom(String from, String toDir) { + File testDir = getTestDir() + def src = new File(testDir, from) + def pomInfo = readPomInfo(src) + def dstDir = new File(testDir, toDir) + dstDir.mkdirs() + def toFile = new File(dstDir, "$pomInfo.artifactId-${pomInfo.version}.pom") + toFile.text = src.text + return toFile +} + +static def readPomInfo(File pomFile) { + MavenXpp3Reader reader = new MavenXpp3Reader() + pomFile.withReader { r -> + Model model = reader.read(r) + + // Inherit groupId/version from parent if missing + if (!model.groupId && model.parent) { + model.groupId = model.parent.groupId + } + if (!model.version && model.parent) { + model.version = model.parent.version + } + + return [ + groupId : model.groupId, + artifactId : model.artifactId, + version : model.version + ] + } +} + +File getTestDir() { + if (binding.hasVariable('project')) { + // from gmavenplus + return binding.getVariable('project').basedir as File + } + def bd + if (binding.hasVariable('basedir')) { + // from the invoker plugin + bd = binding.getVariable('basedir') + } else { + // from command line (e.g. with -Dbasedir=$PWD) + bd = System.getProperty("basedir") + } + bd instanceof File ? bd : new File(bd) +} + diff --git a/src/it/central-zip-bundles/invoker.properties b/src/it/central-zip-bundles/invoker.properties new file mode 100644 index 00000000..1f3fd891 --- /dev/null +++ b/src/it/central-zip-bundles/invoker.properties @@ -0,0 +1,18 @@ +# 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. + +invoker.goals = deploy \ No newline at end of file diff --git a/src/it/central-zip-bundles/pom.xml b/src/it/central-zip-bundles/pom.xml new file mode 100644 index 00000000..e14ac93f --- /dev/null +++ b/src/it/central-zip-bundles/pom.xml @@ -0,0 +1,161 @@ + + + + 4.0.0 + se.alipsa.maven.example + publishing-example-parent + 1.0.0 + + Maven Central Publishing Example, Aggregator + https://github.com/perNyfelt/maven-central-publishing-example + pom + + + common + subA + + + Maven Central Publishing Example, Aggregator + + + Apache-2.0 + https://www.apache.org/licenses/LICENSE-2.0.txt + + + + + pnyfelt + Per Nyfelt + + + + https://github.com/perNyfelt/maven-central-publishing-example/tree/master + scm:git:https://github.com/perNyfelt/maven-central-publishing-example.git + scm:git:https://github.com/perNyfelt/maven-central-publishing-example.git + + + UTF-8 + UTF-8 + 1.8 + 1.8 + + + + + central + Central Repository + https://central.sonatype.com/api/v1 + + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + @mavenCompilerPluginVersion@ + + ${maven.compiler.source} + ${maven.compiler.target} + + -Xlint:-options + + + + + org.apache.maven.plugins + maven-deploy-plugin + @project.version@ + + true + false + false + + + + org.apache.maven.plugins + maven-javadoc-plugin + @mavenJavadocPluginVersion@ + + + attach-javadocs + + jar + + + + + + org.apache.maven.plugins + maven-source-plugin + @mavenSourcePluginVersion@ + + + attach-sources + + jar + + + + + + + + + + org.codehaus.gmavenplus + gmavenplus-plugin + 1.13.1 + + + simulate-signing + verify + + execute + + + + fakeSign.groovy + + + + + + + org.apache.groovy + groovy + 4.0.27 + + + org.apache.groovy + groovy-ant + 4.0.27 + + + + + org.apache.maven.plugins + maven-deploy-plugin + + + + \ No newline at end of file diff --git a/src/it/central-zip-bundles/subA/pom.xml b/src/it/central-zip-bundles/subA/pom.xml new file mode 100644 index 00000000..5aa7e3a3 --- /dev/null +++ b/src/it/central-zip-bundles/subA/pom.xml @@ -0,0 +1,70 @@ + + + + + 4.0.0 + + se.alipsa.maven.example + publishing-example-parent + 1.0.0 + + publishing-example-subA + Maven Central Publishing Example, SubA + https://github.com/perNyfelt/maven-central-publishing-example + jar + + Maven Central Publishing Example, SubA + + + Apache-2.0 + https://www.apache.org/licenses/LICENSE-2.0.txt + + + + + pnyfelt + Per Nyfelt + + + + https://github.com/perNyfelt/maven-central-publishing-example/tree/master + scm:git:https://github.com/perNyfelt/maven-central-publishing-example.git + scm:git:https://github.com/perNyfelt/maven-central-publishing-example.git + + + + se.alipsa.maven.example + publishing-example-common + 1.0.0 + + + + + + org.apache.maven.plugins + maven-source-plugin + + + org.apache.maven.plugins + maven-javadoc-plugin + + + + \ No newline at end of file diff --git a/src/it/central-zip-bundles/subA/readme.md b/src/it/central-zip-bundles/subA/readme.md new file mode 100644 index 00000000..31f35ed8 --- /dev/null +++ b/src/it/central-zip-bundles/subA/readme.md @@ -0,0 +1,17 @@ + +# The subA sub projects of the multmodule project \ No newline at end of file diff --git a/src/it/central-zip-bundles/subA/src/main/java/multimodule/suba/Hello.java b/src/it/central-zip-bundles/subA/src/main/java/multimodule/suba/Hello.java new file mode 100644 index 00000000..7593984d --- /dev/null +++ b/src/it/central-zip-bundles/subA/src/main/java/multimodule/suba/Hello.java @@ -0,0 +1,37 @@ +/* + * 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 multimodule.suba; + +import multimodule.common.Greeting; + +/** + * This is the Greeting implementation in subA + */ +public class Hello implements Greeting { + + /** + * Default constructor. + */ + public Hello() {} + + @Override + public String greet(String name) { + return "Hello $name from suba"; + } +} diff --git a/src/it/central-zip-bundles/verify.groovy b/src/it/central-zip-bundles/verify.groovy new file mode 100644 index 00000000..d8576367 --- /dev/null +++ b/src/it/central-zip-bundles/verify.groovy @@ -0,0 +1,101 @@ +/* + * 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. + */ +import java.util.zip.ZipFile + +// Verify that bundles exists +def aggregatorZip = new File(getBaseDir(), "target/publishing-example-parent-1.0.0-bundle.zip") +def commonZip = new File(getBaseDir(), "common/target/publishing-example-common-1.0.0-bundle.zip") +def subAZip = new File(getBaseDir(), "subA/target/publishing-example-subA-1.0.0-bundle.zip") +assert aggregatorZip.exists() +assert commonZip.exists() +assert subAZip.exists() + +// Check the content of each file +String groupId = "se.alipsa.maven.example" +def expectedAggregatorEntries = { String artifactId, String version -> + String basePath = "${groupId.replace('.', '/')}/${artifactId}/${version}/" + + String artifactPath = basePath + artifactId + '-' + version + [ + artifactPath + '.pom', + artifactPath +'.pom.asc', + artifactPath +'.pom.md5', + artifactPath +'.pom.sha1', + artifactPath +'.pom.sha256' + ] +} + +def expectedFullEntries = {String artifactId, String version -> + String basePath = "${groupId.replace('.', '/')}/${artifactId}/${version}/" + String artifactPath = basePath + artifactId + '-' + version + [ + artifactPath + '.jar', + artifactPath + '.jar.asc', + artifactPath + '.jar.md5', + artifactPath + '.jar.sha1', + artifactPath + '.jar.sha256', + artifactPath + '-sources.jar', + artifactPath + '-sources.jar.asc', + artifactPath + '-sources.jar.md5', + artifactPath + '-sources.jar.sha1', + artifactPath + '-sources.jar.sha256', + artifactPath + '-javadoc.jar', + artifactPath + '-javadoc.jar.asc', + artifactPath + '-javadoc.jar.md5', + artifactPath + '-javadoc.jar.sha1', + artifactPath + '-javadoc.jar.sha256' + ] + expectedAggregatorEntries(artifactId, version) +} +checkZipContent(aggregatorZip, "publishing-example-parent", "1.0.0", expectedAggregatorEntries) +checkZipContent(commonZip, "publishing-example-common", "1.0.0", expectedFullEntries) +checkZipContent(subAZip, "publishing-example-subA", "1.0.0", expectedFullEntries) + +static def checkZipContent(File zipFile, String artifactId, String version, Closure expectedMethod) { + + println "Checking content of $zipFile" + List expectedEntries = expectedMethod(artifactId, version) + List actualEntries + try (ZipFile zip = new ZipFile(zipFile)) { + actualEntries = zip.entries().collect { it.name } + } + expectedEntries.each { + //println " - checking $it" + assert actualEntries.contains(it) : "Expected entry not found in $zipFile.name: $it" + } + assert expectedEntries.size() == actualEntries.size() : "Mismatch in number of entries in ZIP" + +} + +// make it possible to run this script from different locations (command line, gmavenplus, invoker) +File getBaseDir() { + if (binding.hasVariable('project')) { + // from gmavenplus + return binding.getVariable('project').basedir as File + } + def bd + if (binding.hasVariable('basedir')) { + // from the invoker plugin + bd = binding.getVariable('basedir') + } else { + // from command line (e.g. with -Dbasedir=$PWD) + bd = System.getProperty("basedir") + } + if (bd == null) bd = '.' + bd instanceof File ? bd : new File(bd) +} \ No newline at end of file diff --git a/src/it/central-zip-megabundle/README.md b/src/it/central-zip-megabundle/README.md new file mode 100644 index 00000000..b46e26e8 --- /dev/null +++ b/src/it/central-zip-megabundle/README.md @@ -0,0 +1,47 @@ + +# maven-central-publishing-example + +This is a multimodule example showing deployment to maven central +using the new central publishing rest api. + +The maven-deploy-plugin used is a [modified fork](https://github.com/perNyfelt/maven-deploy-plugin/tree/add_central_support) of the apache maven deploy plugin. + +The project consist of +- an aggregator +- a common sub module +- two sub modules that each depends on the common submodule + +Each module (including the main aggregator) will be deployed together. +I.e. when deploying the whole project, 1 zip file will be created and uploaded to central: +This 1 zip will contain: +1. The aggregator pom (+ asc, md5 and sha1 files) +2. common, contains the pom, jar, javadoc and sources (all signed and with md5 and sha1 files) +3. subA, contains the pom, jar, javadoc and sources (all signed and with md5 and sha1 files) +4. subB, contains the pom, jar, javadoc and sources (all signed and with md5 and sha1 files) + +## Running only this test +```shell +mvn -Prun-its verify -Dinvoker.test=central-zip-megabundle +``` + +## Running the test manually +```shell +CLASSPATH=$(find "$MAVEN_HOME/lib" -name "*.jar" | tr '\n' ':' | sed 's/:$//') +mvn deploy +groovy -cp $CLASSPATH -Dbasedir=$PWD verify.groovy +``` \ No newline at end of file diff --git a/src/it/central-zip-megabundle/common/pom.xml b/src/it/central-zip-megabundle/common/pom.xml new file mode 100644 index 00000000..321a88a9 --- /dev/null +++ b/src/it/central-zip-megabundle/common/pom.xml @@ -0,0 +1,63 @@ + + + + + 4.0.0 + + se.alipsa.maven.example + publishing-example-parent + 1.0.0 + + publishing-example-common + Maven Central Publishing Example, Common + https://github.com/perNyfelt/maven-central-publishing-example + jar + + Maven Central Publishing Example, Common + + + Apache-2.0 + https://www.apache.org/licenses/LICENSE-2.0.txt + + + + + pnyfelt + Per Nyfelt + + + + https://github.com/perNyfelt/maven-central-publishing-example/tree/master + scm:git:https://github.com/perNyfelt/maven-central-publishing-example.git + scm:git:https://github.com/perNyfelt/maven-central-publishing-example.git + + + + + org.apache.maven.plugins + maven-javadoc-plugin + + + org.apache.maven.plugins + maven-source-plugin + + + + \ No newline at end of file diff --git a/src/it/central-zip-megabundle/common/readme.md b/src/it/central-zip-megabundle/common/readme.md new file mode 100644 index 00000000..32cc86d8 --- /dev/null +++ b/src/it/central-zip-megabundle/common/readme.md @@ -0,0 +1,17 @@ + +# This is the common module for the multimodule project \ No newline at end of file diff --git a/src/it/central-zip-megabundle/common/src/main/java/multimodule/common/Greeting.java b/src/it/central-zip-megabundle/common/src/main/java/multimodule/common/Greeting.java new file mode 100644 index 00000000..0a9be567 --- /dev/null +++ b/src/it/central-zip-megabundle/common/src/main/java/multimodule/common/Greeting.java @@ -0,0 +1,40 @@ +/* + * 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 multimodule.common; + +/** + * Common interface to define a greeting. + */ +public interface Greeting { + + /** + * Hello {name}. + * + * @param name the name to greet + * @return Hello {name} + */ + String greet(String name); + + /** + * Prints some info. + */ + default void info() { + System.out.println(this.getClass()); + } +} \ No newline at end of file diff --git a/src/it/central-zip-megabundle/fakeSign.groovy b/src/it/central-zip-megabundle/fakeSign.groovy new file mode 100644 index 00000000..b4320453 --- /dev/null +++ b/src/it/central-zip-megabundle/fakeSign.groovy @@ -0,0 +1,86 @@ +/* + * 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. + */ +import org.apache.maven.model.Model +import org.apache.maven.model.io.xpp3.MavenXpp3Reader + +// Since we do not want to mess with external gpg config, we just create fake asc files +signFile(copyPom("pom.xml", "target")) +signJarFiles("target") + +def signJarFiles(String dir) { + def targetDir = new File(getTestDir(), dir) + targetDir.listFiles({ file -> file.name.endsWith(".jar") } as FileFilter).each { + signFile(it) + } +} + +static def signFile(File file) { + def ascFile = new File(file.getParentFile(), "${file.getName()}.asc") + println "Setup: creating signing file: $ascFile" + ascFile << "fake signed" +} + +def copyPom(String from, String toDir) { + File testDir = getTestDir() + def src = new File(testDir, from) + def pomInfo = readPomInfo(src) + def dstDir = new File(testDir, toDir) + dstDir.mkdirs() + def toFile = new File(dstDir, "$pomInfo.artifactId-${pomInfo.version}.pom") + toFile.text = src.text + return toFile +} + +static def readPomInfo(File pomFile) { + MavenXpp3Reader reader = new MavenXpp3Reader() + pomFile.withReader { r -> + Model model = reader.read(r) + + // Inherit groupId/version from parent if missing + if (!model.groupId && model.parent) { + model.groupId = model.parent.groupId + } + if (!model.version && model.parent) { + model.version = model.parent.version + } + + return [ + groupId : model.groupId, + artifactId : model.artifactId, + version : model.version + ] + } +} + +File getTestDir() { + if (binding.hasVariable('project')) { + // from gmavenplus + return binding.getVariable('project').basedir as File + } + def bd + if (binding.hasVariable('basedir')) { + // from the invoker plugin + bd = binding.getVariable('basedir') + } else { + // from command line (e.g. with -Dbasedir=$PWD) + bd = System.getProperty("basedir") + } + bd instanceof File ? bd : new File(bd) +} + diff --git a/src/it/central-zip-megabundle/invoker.properties b/src/it/central-zip-megabundle/invoker.properties new file mode 100644 index 00000000..a8c02377 --- /dev/null +++ b/src/it/central-zip-megabundle/invoker.properties @@ -0,0 +1,18 @@ +# 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. + +invoker.goals = deploy diff --git a/src/it/central-zip-megabundle/pom.xml b/src/it/central-zip-megabundle/pom.xml new file mode 100644 index 00000000..996ced9a --- /dev/null +++ b/src/it/central-zip-megabundle/pom.xml @@ -0,0 +1,161 @@ + + + + 4.0.0 + se.alipsa.maven.example + publishing-example-parent + 1.0.0 + + Maven Central Publishing Example, Aggregator + https://github.com/perNyfelt/maven-central-publishing-example + pom + + + common + subA + + + Maven Central Publishing Example, Aggregator + + + Apache-2.0 + https://www.apache.org/licenses/LICENSE-2.0.txt + + + + + pnyfelt + Per Nyfelt + + + + https://github.com/perNyfelt/maven-central-publishing-example/tree/master + scm:git:https://github.com/perNyfelt/maven-central-publishing-example.git + scm:git:https://github.com/perNyfelt/maven-central-publishing-example.git + + + UTF-8 + UTF-8 + 1.8 + 1.8 + + + + + central + Central Repository + https://central.sonatype.com/api/v1 + + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + @mavenCompilerPluginVersion@ + + ${maven.compiler.source} + ${maven.compiler.target} + + -Xlint:-options + + + + + org.apache.maven.plugins + maven-deploy-plugin + @project.version@ + + true + true + false + + + + org.apache.maven.plugins + maven-javadoc-plugin + @mavenJavadocPluginVersion@ + + + attach-javadocs + + jar + + + + + + org.apache.maven.plugins + maven-source-plugin + @mavenSourcePluginVersion@ + + + attach-sources + + jar + + + + + + + + + + org.codehaus.gmavenplus + gmavenplus-plugin + 1.13.1 + + + simulate-signing + verify + + execute + + + + fakeSign.groovy + + + + + + + org.apache.groovy + groovy + 4.0.27 + + + org.apache.groovy + groovy-ant + 4.0.27 + + + + + org.apache.maven.plugins + maven-deploy-plugin + + + + \ No newline at end of file diff --git a/src/it/central-zip-megabundle/subA/pom.xml b/src/it/central-zip-megabundle/subA/pom.xml new file mode 100644 index 00000000..04244dbe --- /dev/null +++ b/src/it/central-zip-megabundle/subA/pom.xml @@ -0,0 +1,71 @@ + + + + + 4.0.0 + + se.alipsa.maven.example + publishing-example-parent + 1.0.0 + + publishing-example-subA + Maven Central Publishing Example, SubA + https://github.com/perNyfelt/maven-central-publishing-example + jar + + Maven Central Publishing Example, SubA + + + Apache-2.0 + https://www.apache.org/licenses/LICENSE-2.0.txt + + + + + pnyfelt + Per Nyfelt + + + + https://github.com/perNyfelt/maven-central-publishing-example/tree/master + scm:git:https://github.com/perNyfelt/maven-central-publishing-example.git + scm:git:https://github.com/perNyfelt/maven-central-publishing-example.git + + + + se.alipsa.maven.example + publishing-example-common + 1.0.0 + + + + + + + org.apache.maven.plugins + maven-javadoc-plugin + + + org.apache.maven.plugins + maven-source-plugin + + + + \ No newline at end of file diff --git a/src/it/central-zip-megabundle/subA/readme.md b/src/it/central-zip-megabundle/subA/readme.md new file mode 100644 index 00000000..31f35ed8 --- /dev/null +++ b/src/it/central-zip-megabundle/subA/readme.md @@ -0,0 +1,17 @@ + +# The subA sub projects of the multmodule project \ No newline at end of file diff --git a/src/it/central-zip-megabundle/subA/src/main/java/multimodule/suba/Hello.java b/src/it/central-zip-megabundle/subA/src/main/java/multimodule/suba/Hello.java new file mode 100644 index 00000000..7593984d --- /dev/null +++ b/src/it/central-zip-megabundle/subA/src/main/java/multimodule/suba/Hello.java @@ -0,0 +1,37 @@ +/* + * 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 multimodule.suba; + +import multimodule.common.Greeting; + +/** + * This is the Greeting implementation in subA + */ +public class Hello implements Greeting { + + /** + * Default constructor. + */ + public Hello() {} + + @Override + public String greet(String name) { + return "Hello $name from suba"; + } +} diff --git a/src/it/central-zip-megabundle/verify.groovy b/src/it/central-zip-megabundle/verify.groovy new file mode 100644 index 00000000..a13115e5 --- /dev/null +++ b/src/it/central-zip-megabundle/verify.groovy @@ -0,0 +1,117 @@ +/* + * 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. + */ +import java.util.zip.ZipFile + +// Verify that bundles exists +def megaZip = new File(getBaseDir(), "target/se.alipsa.maven.example-1.0.0-bundle.zip") +assert megaZip.exists() +// We should NOT have any bundles for the sub modules +def aggregatorZip = new File(getBaseDir(), "target/publishing-example-parent-1.0.0-bundle.zip") +def commonZip = new File(getBaseDir(), "common/target/publishing-example-common-1.0.0-bundle.zip") +def subAZip = new File(getBaseDir(), "subA/target/publishing-example-subA-1.0.0-bundle.zip") +assert !aggregatorZip.exists() +assert !commonZip.exists() +assert !subAZip.exists() + +// Check the content of the bundle +String groupId = "se.alipsa.maven.example" +def expectedAggregatorEntries = { String artifactId, String version -> + String basePath = "${groupId.replace('.', '/')}/${artifactId}/${version}/" + + String artifactPath = basePath + artifactId + '-' + version + [ + artifactPath + '.pom', + artifactPath +'.pom.asc', + artifactPath +'.pom.md5', + artifactPath +'.pom.sha1', + artifactPath +'.pom.sha256' + ] +} + +def expectedFullEntries = {String artifactId, String version -> + String basePath = "${groupId.replace('.', '/')}/${artifactId}/${version}/" + String artifactPath = basePath + artifactId + '-' + version + [ + artifactPath + '.jar', + artifactPath + '.jar.asc', + artifactPath + '.jar.md5', + artifactPath + '.jar.sha1', + artifactPath + '.jar.sha256', + artifactPath + '-sources.jar', + artifactPath + '-sources.jar.asc', + artifactPath + '-sources.jar.md5', + artifactPath + '-sources.jar.sha1', + artifactPath + '-sources.jar.sha256', + artifactPath + '-javadoc.jar', + artifactPath + '-javadoc.jar.asc', + artifactPath + '-javadoc.jar.md5', + artifactPath + '-javadoc.jar.sha1', + artifactPath + '-javadoc.jar.sha256' + ] + expectedAggregatorEntries(artifactId, version) +} +// Ensure aggregator, common and subA files exists in the mega bundle + +checkZipContent(megaZip, "publishing-example-parent", "1.0.0", expectedAggregatorEntries) +checkZipContent(megaZip, "publishing-example-common", "1.0.0", expectedFullEntries) +checkZipContent(megaZip, "publishing-example-subA", "1.0.0", expectedFullEntries) +List aggregatorEntries = expectedAggregatorEntries("publishing-example-parent", "1.0.0") +List commonEntries = expectedFullEntries("publishing-example-common", "1.0.0") +List subAEntries = expectedFullEntries("publishing-example-subA", "1.0.0") + +def actualEntries +try (ZipFile zip = new ZipFile(megaZip)) { + actualEntries = zip.entries().collect { it.name } +} +int expectedEntries = aggregatorEntries.size() + commonEntries.size() + subAEntries.size() +assert expectedEntries == actualEntries.size() : "Mismatch in number of entries in ZIP, expected $expectedEntries but was ${actualEntries.size()}" + +static def checkZipContent(File zipFile, String artifactId, String version, Closure expectedMethod) { + println "Checking content of $zipFile" + List expectedEntries = expectedMethod(artifactId, version) + List actualEntries + try (ZipFile zip = new ZipFile(zipFile)) { + actualEntries = zip.entries().collect { it.name } + } + expectedEntries.each { + //println " - checking $it" + assert actualEntries.contains(it) : "Expected entry not found in $zipFile.name: $it" + } + return actualEntries +} + +// make it possible to run this script from different locations (command line, gmavenplus, invoker) +File getBaseDir() { + if (binding.hasVariable('project')) { + // from gmavenplus + return binding.getVariable('project').basedir as File + } + def bd + if (binding.hasVariable('basedir')) { + // from the invoker plugin + bd = binding.getVariable('basedir') + } else { + // from command line (e.g. with -Dbasedir=$PWD) + bd = System.getProperty("basedir") + } + if (bd == null) { + // invoked from command line and forgot to set property, assume we are in the project root + bd = "." + } + bd instanceof File ? bd : new File(bd) +} \ No newline at end of file diff --git a/src/it/settings.xml b/src/it/settings.xml index c8f77f0b..5fa6a3f5 100644 --- a/src/it/settings.xml +++ b/src/it/settings.xml @@ -52,4 +52,11 @@ under the License. + + + central + testUser + testPwd + + diff --git a/src/main/java/org/apache/maven/plugins/deploy/Bundler.java b/src/main/java/org/apache/maven/plugins/deploy/Bundler.java new file mode 100644 index 00000000..693215a6 --- /dev/null +++ b/src/main/java/org/apache/maven/plugins/deploy/Bundler.java @@ -0,0 +1,234 @@ +/* + * 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.FileReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.Reader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.security.DigestOutputStream; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +import org.apache.maven.artifact.Artifact; +import org.apache.maven.model.Model; +import org.apache.maven.model.io.xpp3.MavenXpp3Reader; +import org.apache.maven.plugin.MojoExecutionException; +import org.apache.maven.plugin.logging.Log; +import org.apache.maven.project.MavenProject; +import org.codehaus.plexus.util.xml.pull.XmlPullParserException; +import org.eclipse.aether.util.ChecksumUtils; + +public class Bundler { + + MavenProject project; + Log log; + + public Bundler(MavenProject project, Log log) { + this.project = project; + this.log = log; + } + + static final List CHECKSUM_ALGOS = Arrays.asList("MD5", "SHA-1", "SHA-256"); + + /** + * Create a mega bundle zip with the artifacts for all projects in this build.. + * This method requires that the "verify" phase has been executed and gpg signing has been configured. + * + * @param bundleFile the zip file to create + * @throws IOException if creating the zip file failed + * @throws NoSuchAlgorithmException if md5, sha-1 or sha-256 algorithms are not available in + * the environment. + */ + public void createZipBundle(File bundleFile, List allProjectsUsingPlugin) + throws IOException, NoSuchAlgorithmException, MojoExecutionException { + bundleFile.getParentFile().mkdirs(); + bundleFile.createNewFile(); + + Map> artifactFiles = new HashMap<>(); + for (MavenProject project : allProjectsUsingPlugin) { + String groupId = project.getGroupId(); + String artifactId = project.getArtifactId(); + String version = project.getVersion(); + String groupPath = groupId.replace('.', '/'); + String mavenPathPrefix = String.join("/", groupPath, artifactId, version) + "/"; + artifactFiles.computeIfAbsent(mavenPathPrefix, k -> new ArrayList<>()); + File artifactFile = project.getArtifact().getFile(); + // Will be null for e.g., an aggregator project + if (artifactFile != null && artifactFile.exists()) { + artifactFiles.get(mavenPathPrefix).add(artifactFile); + File signFile = new File(artifactFile.getAbsolutePath() + ".asc"); + if (!signFile.exists()) { + throw new MojoExecutionException(artifactFile + " is not signed, " + signFile + " is missing"); + } + artifactFiles.get(mavenPathPrefix).add(signFile); + } + // pom is not in getAttachedArtifacts so add it explicitly + File pomFile = new File(project.getBuild().getDirectory(), String.join("-", artifactId, version) + ".pom"); + if (pomFile.exists()) { + // Since it is the "raw" pom file that is published, not the effective pom, we must check the file, + // not the project. Also since the pom file is the signed one, we cannot change it to the effective pom. + validateForPublishing(pomFile); + + artifactFiles.get(mavenPathPrefix).add(pomFile); + File signFile = new File(pomFile.getAbsolutePath() + ".asc"); + if (!signFile.exists()) { + throw new MojoExecutionException( + "POM file " + pomFile + " is not signed, " + signFile + " is missing"); + } + artifactFiles.get(mavenPathPrefix).add(signFile); + + } else { + log.error("POM file " + pomFile + " does not exist (verify phase not reached)!"); + throw new MojoExecutionException("POM file " + pomFile + " does not exist!"); + } + + for (Artifact artifact : project.getAttachedArtifacts()) { + File file = artifact.getFile(); + if (file.exists()) { + artifactFiles.get(mavenPathPrefix).add(artifact.getFile()); + } else { + log.error("Artifact " + artifact.getId() + " does not exist!"); + } + File signFile = new File(file.getAbsolutePath() + ".asc"); + if (!signFile.exists()) { + throw new MojoExecutionException("File " + file + " is not signed, " + signFile + " is missing"); + } + artifactFiles.get(mavenPathPrefix).add(signFile); + } + } + + try (ZipOutputStream zipOut = new ZipOutputStream(Files.newOutputStream(bundleFile.toPath()))) { + for (Map.Entry> entry : artifactFiles.entrySet()) { + String mavenPathPrefix = entry.getKey(); + for (File file : entry.getValue()) { + addToZip(file, mavenPathPrefix, zipOut); + if (file.getName().endsWith(".asc")) { + continue; // asc files has no checksums + } + // Ensure the artifact is signed before continuing to create and add checksums + File signFile = new File(file.getAbsolutePath() + ".asc"); + if (!signFile.exists()) { + throw new MojoExecutionException( + "The artifact " + file + " was not signed! " + signFile + " does not exists"); + } + generateChecksumsAndAddToZip(file, mavenPathPrefix, zipOut); + } + } + } + } + + /** + * Create a bundle zip with the artifacts for the current project only. + * This method requires that the "verify" phase has been executed and gpg signing has been configured. + * e.g. mvn verify deploy:bundle + * + * @param bundleFile the zip file to create + * @throws IOException if creating the zip file failed + * @throws NoSuchAlgorithmException if md5, sha-1 or sha-256 algorithms are not available in + * the environment. + */ + public void createZipBundle(File bundleFile) throws IOException, NoSuchAlgorithmException, MojoExecutionException { + createZipBundle(bundleFile, Collections.singletonList(project)); + } + + /** + * Validates that the following elements are present (required for publishing to central): + *
    + *
  • Project description
  • + *
  • License information
  • + *
  • SCM URL
  • + *
  • Developers information
  • + *
+ */ + private void validateForPublishing(File pomFile) throws MojoExecutionException { + Model model = readPomFile(pomFile); + List errs = new ArrayList<>(); + if (model.getDescription() == null || model.getDescription().trim().isEmpty()) { + errs.add("description is missing"); + } + if (model.getLicenses() == null || model.getLicenses().isEmpty()) { + errs.add("license is missing"); + } + if (model.getScm() == null) { + errs.add("scm is missing"); + } else if (model.getScm().getUrl() == null) { + errs.add("scm url is missing"); + } + if (model.getDevelopers() == null || model.getDevelopers().isEmpty()) { + errs.add("developers is missing"); + } + + if (!errs.isEmpty()) { + throw new MojoExecutionException(pomFile + " is not valid for publishing: " + String.join(", ", errs)); + } + } + + private void addToZip(File file, String prefix, ZipOutputStream zipOut) throws IOException { + log.debug("Create bundle, addToZip - " + file.getAbsolutePath()); + zipOut.putNextEntry(new ZipEntry(prefix + file.getName())); + Files.copy(file.toPath(), zipOut); + zipOut.closeEntry(); + } + + private void generateChecksumsAndAddToZip(File sourceFile, String prefix, ZipOutputStream zipOut) + throws NoSuchAlgorithmException, IOException { + for (File checksumFile : generateChecksums(sourceFile, CHECKSUM_ALGOS)) { + addToZip(checksumFile, prefix, zipOut); + } + } + + public List generateChecksums(File file, List algos) throws IOException { + Map results = ChecksumUtils.calc(file, algos); + List checkSumFiles = new ArrayList<>(); + for (Map.Entry 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 + + + + +