Files
struts/Jenkinsfile
T
Lukasz Lenart 791d1e3dbd build(ci): baseline the Jenkins .claude/ filter on the PR target branch (#1850)
#1846 guarded the two JDK stages on a diff against
GIT_PREVIOUS_SUCCESSFUL_COMMIT. On a branch build that is the right
baseline. On a pull request build it is not: the pointer is the previous
head of the same PR, so once the PR is rebased - or the target branch is
merged into it - everything the target absorbed in between shows up as a
change of the PR's own.

PR-1848 build #2 is the case. The pull request touches only
.claude/skills/releasing-struts/, but it had been rebased across the
maven.yml fix, and Jenkins computed:

  + base=b633817af047afaa80948404e2e6f1eb78e02b7a
  + git diff --name-only b633817af... HEAD
  + outside=.github/workflows/maven.yml
  Changes outside .claude/: true

so both JDK stages ran a full Maven round trip for a documentation-only
change. Since main almost always carries code, this made the filter
useless for any pull request that is ever brought up to date.

Use the merge base with the target branch as the baseline when
CHANGE_TARGET is set. The multibranch checkout already fetches it -

  git fetch ... +refs/heads/main:refs/remotes/origin/main

- so origin/$CHANGE_TARGET resolves in the workspace. Branch builds have
no target and keep the previous-successful-commit baseline.

Fail-open is unchanged and still covers the new path: an unresolvable
merge base (target branch absent) yields an empty base and reports true.

Exercised against the real commits of #1848 either side of its rebase,
and against synthetic heads for: code only, .claude only, mixed, a
.claudefoo/ near miss, a missing target branch, and the three branch
build baselines. All ten behave as intended.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 19:30:28 +02:00

280 lines
8.3 KiB
Groovy

#!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.
*/
pipeline {
agent none
options {
buildDiscarder logRotator(daysToKeepStr: '14', numToKeepStr: '10')
timeout(80)
disableConcurrentBuilds()
skipStagesAfterUnstable()
quietPeriod(30)
}
triggers {
pollSCM 'H/15 * * * *'
}
stages {
stage('Prepare') {
agent {
label 'ubuntu'
}
stages {
stage('Clean up') {
steps {
cleanWs deleteDirs: true, patterns: [[pattern: '**/target/**', type: 'INCLUDE']]
}
}
stage('Detect changes') {
steps {
script {
// Skip the build when a change only touched .claude/ - agent
// instructions, not code. Fails open: anything unexpected (no
// baseline, an unreachable commit, a git error) reports true and
// the build runs as before.
//
// On a pull request the baseline is the merge base with the
// target branch, NOT GIT_PREVIOUS_SUCCESSFUL_COMMIT. That pointer
// is the previous head of this same PR, so once the PR is rebased
// (or the target is merged into it) everything the target branch
// absorbed in the meantime looks like a change of the PR's own.
// The multibranch checkout already fetches the target branch, so
// origin/$CHANGE_TARGET resolves here. On a branch build there is
// no target and the previous successful commit is the only
// baseline available.
env.CODE_CHANGED = sh(returnStdout: true, script: '''
set -u
target="${CHANGE_TARGET:-}"
if [ -n "$target" ]; then
base=$(git merge-base "origin/${target}" HEAD 2>/dev/null || true)
else
base="${GIT_PREVIOUS_SUCCESSFUL_COMMIT:-}"
fi
if [ -z "$base" ] || ! git cat-file -e "${base}^{commit}" 2>/dev/null; then
echo true
exit 0
fi
outside=$(git diff --name-only "$base" HEAD | grep -vE '^(\\.claude/|$)' || true)
if [ -n "$outside" ]; then
echo true
else
echo false
fi
''').trim()
echo "Changes outside .claude/: ${env.CODE_CHANGED}"
}
}
}
}
}
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 {
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'
}
}
steps {
dir("local-snapshots-dir/") {
deleteDir()
}
sh './mvnw -B source:jar javadoc:jar -DskipTests -DskipAssembly'
}
}
stage('Deploy Snapshot') {
when {
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'
}
}
}
stage('Upload nightlies') {
when {
anyOf {
branch 'main'
branch 'support/struts-6-x-x'
}
}
steps {
sh './mvnw -B package -DskipTests'
sshPublisher(publishers: [
sshPublisherDesc(
configName: 'Nightlies',
transfers: [
sshTransfer(
remoteDirectory: '/struts/snapshot',
removePrefix: 'assembly/target/assembly/out',
sourceFiles: 'assembly/target/assembly/out/struts-*.zip'
)
],
verbose: true
)
])
}
}
}
post {
always {
cleanWs deleteDirs: true, patterns: [[pattern: '**/target/**', type: 'INCLUDE']]
}
}
}
}
post {
// If this build failed, send an email to the list.
failure {
script {
emailext(
to: "notifications@struts.apache.org",
recipientProviders: [[$class: 'DevelopersRecipientProvider']],
from: "Mr. Jenkins <jenkins@builds.apache.org>",
subject: "Jenkins job ${env.JOB_NAME}#${env.BUILD_NUMBER} failed",
body: """
There is a build failure in ${env.JOB_NAME}.
Build: ${env.BUILD_URL}
Logs: ${env.BUILD_URL}console
Changes: ${env.BUILD_URL}changes
--
Mr. Jenkins
Director of Continuous Integration
"""
)
}
}
// If this build didn't fail, but there were failing tests, send an email to the list.
unstable {
script {
emailext(
to: "notifications@struts.apache.org",
recipientProviders: [[$class: 'DevelopersRecipientProvider']],
from: "Mr. Jenkins <jenkins@builds.apache.org>",
subject: "Jenkins job ${env.JOB_NAME}#${env.BUILD_NUMBER} unstable",
body: """
Some tests have failed in ${env.JOB_NAME}.
Build: ${env.BUILD_URL}
Logs: ${env.BUILD_URL}console
Changes: ${env.BUILD_URL}changes
--
Mr. Jenkins
Director of Continuous Integration
"""
)
}
}
// Send an email, if the last build was not successful and this one is.
fixed {
script {
emailext(
to: "notifications@struts.apache.org",
recipientProviders: [[$class: 'DevelopersRecipientProvider']],
from: 'Mr. Jenkins <jenkins@builds.apache.org>',
subject: "Jenkins job ${env.JOB_NAME}#${env.BUILD_NUMBER} back to normal",
body: """
The build for ${env.JOB_NAME} completed successfully and is back to normal.
Build: ${env.BUILD_URL}
Logs: ${env.BUILD_URL}console
Changes: ${env.BUILD_URL}changes
--
Mr. Jenkins
Director of Continuous Integration
"""
)
}
}
}
}