build(ci): test once on JDK 17 in Jenkins, reserve Jetty ports for the showcase ITs (#1967)

Jenkins ran the suite on JDK 21 and again on JDK 17 with -Pcoverage,
then re-compiled the tree three more times for install, deploy and the
nightlies. GitHub Actions already covers JDK 17, 21, 25 and the Jakarta
EE 11 profile, and the JaCoCo data had no consumer on Jenkins. Jenkins
now runs one verify on JDK 17 and keeps only what GitHub cannot do: the
snapshot deploy and the nightlies upload. The deploy also passes
-DskipITs: failsafe 3.6.0 has no skipTests parameter, so the showcase
integration tests were booting Jetty on every "skipped" invocation. The
nightlies step builds only the assembly module; the preceding deploy has
already installed every other module and the descriptors read the
sibling target/ directories of the same workspace.

The old "Build Source & JavaDoc" stage ran in its own Maven session, so
its jars were never attached to the deploy; the snapshot repository has
only the main jar and pom. Dropping it changes nothing that is
published.

The showcase integration tests bound Jetty to a fixed 8090/8999.
disableConcurrentBuilds() serialises only builds of the same branch
job, so main and a pull request landing on one agent collided, and the
pre-start "stop" goal with the shared stop key could shut down the
neighbouring build's server. build-helper now reserves free ports for
the start/stop executions; the plugin-level 8090/8999 stay in place for
an interactive jetty:run. The port properties are deliberately not
declared under <properties>: a declared property is interpolated into
plugin configuration at model-build time, before build-helper runs, and
the default would win everywhere.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
diff --git a/Jenkinsfile b/Jenkinsfile
index 5709b53..b58498f 100644
--- a/Jenkinsfile
+++ b/Jenkinsfile
@@ -31,10 +31,17 @@
     pollSCM 'H/15 * * * *'
   }
   stages {
-    stage('Prepare') {
+    stage('JDK 17') {
       agent {
         label 'ubuntu'
       }
+      tools {
+        jdk 'jdk_17_latest'
+        maven 'maven_3_latest'
+      }
+      environment {
+        MAVEN_OPTS = "-Xmx2048m"
+      }
       stages {
         stage('Clean up') {
           steps {
@@ -83,108 +90,57 @@
             }
           }
         }
-      }
-    }
-    stage('JDK 21') {
-      when {
-        expression { env.CODE_CHANGED != 'false' }
-      }
-      agent {
-        label 'ubuntu'
-      }
-      tools {
-        jdk 'jdk_21_latest'
-        maven 'maven_3_latest'
-      }
-      environment {
-        MAVEN_OPTS = "-Xmx1024m"
-      }
-      stages {
+        // Tests run once, on the baseline JDK. The other supported JDKs and
+        // the Jakarta EE 11 profile are covered by .github/workflows/maven.yml;
+        // Jenkins exists for what GitHub Actions cannot do: the snapshot deploy
+        // and the nightlies upload.
         stage('Test') {
-          steps {
-            sh './mvnw -B -DskipAssembly verify'
-          }
-          post {
-            always {
-              junit(testResults: '**/surefire-reports/*.xml', allowEmptyResults: true)
-              junit(testResults: '**/failsafe-reports/*.xml', allowEmptyResults: true)
-            }
-          }
-        }
-      }
-      post {
-        always {
-          cleanWs deleteDirs: true, patterns: [[pattern: '**/target/**', type: 'INCLUDE']]
-        }
-      }
-    }
-    stage('JDK 17') {
-      when {
-        expression { env.CODE_CHANGED != 'false' }
-      }
-      agent {
-        label 'ubuntu'
-      }
-      tools {
-        jdk 'jdk_17_latest'
-        maven 'maven_3_latest'
-      }
-      environment {
-        MAVEN_OPTS = "-Xmx2048m"
-      }
-      stages {
-        stage('Install') {
-          steps {
-            sh './mvnw -B install -DskipTests -DskipAssembly'
-          }
-        }
-        stage('Test') {
-          steps {
-            sh './mvnw -B verify -Pcoverage -DskipAssembly'
-          }
-          post {
-            always {
-              junit(testResults: '**/surefire-reports/*.xml', allowEmptyResults: true)
-              junit(testResults: '**/failsafe-reports/*.xml', allowEmptyResults: true)
-            }
-          }
-        }
-        stage('Build Source & JavaDoc') {
           when {
-            anyOf {
-              branch 'main'
-              branch 'support/struts-6-x-x'
-            }
+            expression { env.CODE_CHANGED != 'false' }
           }
           steps {
-            dir("local-snapshots-dir/") {
-              deleteDir()
+            sh './mvnw -B -DskipAssembly verify --no-transfer-progress'
+          }
+          post {
+            always {
+              junit(testResults: '**/surefire-reports/*.xml', allowEmptyResults: true)
+              junit(testResults: '**/failsafe-reports/*.xml', allowEmptyResults: true)
             }
-            sh './mvnw -B source:jar javadoc:jar -DskipTests -DskipAssembly'
           }
         }
+        // -DskipITs as well as -DskipTests: failsafe does not know skipTests,
+        // so without it the showcase integration tests boot Jetty again here.
         stage('Deploy Snapshot') {
           when {
-            anyOf {
-              branch 'main'
-              branch 'support/struts-6-x-x'
+            allOf {
+              expression { env.CODE_CHANGED != 'false' }
+              anyOf {
+                branch 'main'
+                branch 'support/struts-6-x-x'
+              }
             }
           }
           steps {
             withCredentials([file(credentialsId: 'lukaszlenart-repository-access-token', variable: 'CUSTOM_SETTINGS')]) {
-              sh './mvnw -s \${CUSTOM_SETTINGS} deploy -DskipTests -DskipAssembly'
+              sh './mvnw -B -s \${CUSTOM_SETTINGS} deploy -DskipTests -DskipITs -DskipAssembly --no-transfer-progress'
             }
           }
         }
+        // The deploy above installed every module, so only the assembly
+        // module needs building; its descriptors read the sibling modules'
+        // target/ directories from this same workspace.
         stage('Upload nightlies') {
           when {
-            anyOf {
-              branch 'main'
-              branch 'support/struts-6-x-x'
+            allOf {
+              expression { env.CODE_CHANGED != 'false' }
+              anyOf {
+                branch 'main'
+                branch 'support/struts-6-x-x'
+              }
             }
           }
           steps {
-            sh './mvnw -B package -DskipTests'
+            sh './mvnw -B package -DskipTests -DskipITs -pl assembly --no-transfer-progress'
             sshPublisher(publishers: [
                 sshPublisherDesc(
                     configName: 'Nightlies',
diff --git a/apps/showcase/pom.xml b/apps/showcase/pom.xml
index 123b8ed..43b87f5 100644
--- a/apps/showcase/pom.xml
+++ b/apps/showcase/pom.xml
@@ -230,7 +230,7 @@
                         <include>it.org.apache.struts2.showcase.*Test</include>
                     </includes>
                     <systemPropertyVariables>
-                        <http.port>8090</http.port>
+                        <http.port>${jetty.http.port}</http.port>
                     </systemPropertyVariables>
                 </configuration>
                 <executions>
@@ -249,6 +249,26 @@
                 </executions>
             </plugin>
             <plugin>
+                <groupId>org.codehaus.mojo</groupId>
+                <artifactId>build-helper-maven-plugin</artifactId>
+                <version>3.6.1</version>
+                <executions>
+                    <execution>
+                        <id>reserve-jetty-ports</id>
+                        <phase>pre-integration-test</phase>
+                        <goals>
+                            <goal>reserve-network-port</goal>
+                        </goals>
+                        <configuration>
+                            <portNames>
+                                <portName>jetty.http.port</portName>
+                                <portName>jetty.stop.port</portName>
+                            </portNames>
+                        </configuration>
+                    </execution>
+                </executions>
+            </plugin>
+            <plugin>
                 <groupId>org.eclipse.jetty</groupId>
                 <artifactId>jetty-maven-plugin</artifactId>
                 <version>11.0.26</version>
@@ -279,10 +299,14 @@
                         <id>start-jetty</id>
                         <phase>pre-integration-test</phase>
                         <goals>
-                            <!-- stop any previous instance to free up the port -->
-                            <goal>stop</goal>
                             <goal>start</goal>
                         </goals>
+                        <configuration>
+                            <stopPort>${jetty.stop.port}</stopPort>
+                            <httpConnector>
+                                <port>${jetty.http.port}</port>
+                            </httpConnector>
+                        </configuration>
                     </execution>
                     <execution>
                         <id>stop-jetty</id>
@@ -290,6 +314,9 @@
                         <goals>
                             <goal>stop</goal>
                         </goals>
+                        <configuration>
+                            <stopPort>${jetty.stop.port}</stopPort>
+                        </configuration>
                     </execution>
                 </executions>
              </plugin>