From 57fe8575e3f6e2562ad8950000ac4395d0d5fa8a Mon Sep 17 00:00:00 2001
From: sandeeplocharla
Date: Tue, 13 Oct 2026 08:59:27 +0530
Subject: [PATCH 1/5] Addressed the comments received from Devops team
---
private-cicd/Jenkinsfile | 405 +++++++++++++++---
private-cicd/README.md | 17 +-
private-cicd/docs/CREATE-PRESUBMIT-JOB.md | 22 +-
private-cicd/docs/PRIVATE-CICD-GUIDE.md | 26 +-
private-cicd/scripts/resolve-pull-request.py | 133 ++++++
.../scripts/test_resolve_pull_request.py | 82 ++++
6 files changed, 583 insertions(+), 102 deletions(-)
create mode 100644 private-cicd/scripts/resolve-pull-request.py
create mode 100644 private-cicd/scripts/test_resolve_pull_request.py
diff --git a/private-cicd/Jenkinsfile b/private-cicd/Jenkinsfile
index 8d1dead65701..396154123ba6 100644
--- a/private-cicd/Jenkinsfile
+++ b/private-cicd/Jenkinsfile
@@ -146,6 +146,93 @@ boolean hasIntegrationStarted(build) {
return environment?.get('PRESUBMIT_INTEGRATION_STARTED') == 'true'
}
+@NonCPS
+String revisionHeadSha(revision) {
+ if (revision == null) {
+ return ''
+ }
+ try {
+ def hash = revision.pullHash?.toString() ?: ''
+ if (hash ==~ /^[0-9a-fA-F]{40}$/) {
+ return hash
+ }
+ } catch (Exception ignored) {
+ return ''
+ }
+ return ''
+}
+
+@NonCPS
+String indexedHeadSha(build) {
+ return revisionHeadSha(build?.getAction(jenkins.scm.api.SCMRevisionAction)?.revision)
+}
+
+// A multibranch child only contains builds for one PR. Stop an older run of
+// that child before it holds the VM. A queued duplicate of the same indexed
+// SHA is cancelled; a queued item with no SHA is left alone.
+@NonCPS
+def supersedeMultibranchBuilds(String jobName, int buildNumber, String sha) {
+ def jenkins = jenkins.model.Jenkins.get()
+ def job = jenkins.getItemByFullName(jobName)
+ if (job == null) {
+ throw new IllegalStateException("Cannot find Jenkins job '${jobName}'.")
+ }
+
+ def results = []
+ def latest = revisionHeadSha(job.getAction(jenkins.scm.api.SCMRevisionAction)?.revision)
+ if (latest && sha && !latest.equalsIgnoreCase(sha)) {
+ def self = job.getBuildByNumber(buildNumber)
+ if (self != null && self.isBuilding() && !hasIntegrationStarted(self)) {
+ self.doStop()
+ results << "Stopping build #${buildNumber}: a newer head ${latest.take(12)} is indexed."
+ } else if (self != null && hasIntegrationStarted(self)) {
+ results << "Not stopping build #${buildNumber}: ONTAP integration has started."
+ }
+ return results
+ }
+ jenkins.queue.items.findAll { item ->
+ item.task?.fullName == job.fullName
+ }.each { item ->
+ def queued = revisionHeadSha(item.getAction(jenkins.scm.api.SCMRevisionAction)?.revision)
+ if (queued && sha && queued.equalsIgnoreCase(sha)) {
+ jenkins.queue.cancel(item)
+ results << "Cancelled a queued build for ${sha.take(12)}."
+ }
+ }
+ job.getBuilds().findAll { build ->
+ build.isBuilding() && build.number < buildNumber
+ }.each { build ->
+ if (hasIntegrationStarted(build)) {
+ results << "Not aborting build #${build.number}: ONTAP integration has started."
+ } else {
+ build.doStop()
+ results << "Aborted superseded build #${build.number}."
+ }
+ }
+ return results
+}
+
+@NonCPS
+int diffNumberForResolvedSha(String jobName, int buildNumber, String sha) {
+ def job = jenkins.model.Jenkins.get().getItemByFullName(jobName)
+ if (job == null || !sha) {
+ return 1
+ }
+ def previousShas = [] as Set
+ job.getBuilds().findAll {
+ it.number < buildNumber
+ }.each { build ->
+ def previousSha = build.getAction(
+ org.jenkinsci.plugins.workflow.cps.EnvActionImpl
+ )?.getOverriddenEnvironment()?.get('RESOLVED_SOURCE_SHA')
+ if (previousSha) {
+ previousShas << previousSha
+ }
+ }
+ previousShas.remove(sha)
+ return previousShas.size() + 1
+}
+
@NonCPS
def abortOlderBuildsForSource(String jobName, int buildNumber, String sourceKey) {
def job = jenkins.model.Jenkins.get().getItemByFullName(jobName)
@@ -198,6 +285,44 @@ boolean isValidBranchName(String branchName) {
}
}
+boolean isMultibranchPullRequest() {
+ return env.CHANGE_ID?.trim()
+}
+
+// Branch Source does not fill Declarative parameters. A multibranch run stores
+// one resolved pull-request context in the environment and reads it here.
+def sourceValue(String name) {
+ if (env.PRESUBMIT_MULTIBRANCH_READY == 'true') {
+ switch (name) {
+ case 'SOURCE_MODE':
+ return env.EFFECTIVE_SOURCE_MODE
+ case 'PR_ID':
+ return env.EFFECTIVE_PR_ID
+ case 'PR_ACTION':
+ return env.EFFECTIVE_PR_ACTION
+ case 'PR_DRAFT':
+ return env.EFFECTIVE_PR_DRAFT
+ case 'PR_REPOSITORY':
+ return env.EFFECTIVE_PR_REPOSITORY
+ case 'PR_BASE_BRANCH':
+ return env.EFFECTIVE_PR_BASE_BRANCH
+ case 'PR_HEAD_BRANCH':
+ return env.EFFECTIVE_PR_HEAD_BRANCH
+ case 'PR_HEAD_SHA':
+ return env.EFFECTIVE_PR_HEAD_SHA
+ case 'PR_AUTHOR_LOGIN':
+ return env.EFFECTIVE_PR_AUTHOR_LOGIN
+ case 'PR_AUTHOR_EMAIL':
+ return env.EFFECTIVE_PR_AUTHOR_EMAIL
+ case 'PR_URL':
+ return env.EFFECTIVE_PR_URL
+ case 'PR_TITLE':
+ return env.EFFECTIVE_PR_TITLE
+ }
+ }
+ return params.get(name)
+}
+
def validateInventory(Map inventory) {
if (containsPlaceholder(inventory)) {
error('VM inventory still contains PLACEHOLDER- values.')
@@ -321,13 +446,13 @@ void recordStageEvent(String stageName, String status) {
}
String presubmitSubjectBase() {
- def recipientName = params.PR_AUTHOR_LOGIN?.trim()
+ def recipientName = sourceValue('PR_AUTHOR_LOGIN')?.trim()
if (!recipientName && env.PRESUBMIT_MAIL_TO?.contains('@')) {
recipientName = env.PRESUBMIT_MAIL_TO.substring(0, env.PRESUBMIT_MAIL_TO.indexOf('@'))
}
- def sourceName = params.SOURCE_MODE == 'branch' ?
- "branch ${params.SOURCE_BRANCH}" : "PR-${params.PR_ID}"
- def title = params.PR_TITLE?.replaceAll(/[\r\n]+/, ' ')?.trim()
+ def sourceName = sourceValue('SOURCE_MODE') == 'branch' ?
+ "branch ${params.SOURCE_BRANCH}" : "PR-${sourceValue('PR_ID')}"
+ def title = sourceValue('PR_TITLE')?.replaceAll(/[\r\n]+/, ' ')?.trim()
if (title?.length() > 80) {
title = title.take(77) + '...'
}
@@ -365,15 +490,15 @@ boolean hasGithubAppCredential() {
List githubEnvironment() {
return [
- "GITHUB_REPOSITORY=${params.PR_REPOSITORY}",
- "GITHUB_HEAD_SHA=${params.PR_HEAD_SHA}",
- "PR_ID=${params.PR_ID}",
- "PR_TITLE=${params.PR_TITLE ?: ''}"
+ "GITHUB_REPOSITORY=${sourceValue('PR_REPOSITORY')}",
+ "GITHUB_HEAD_SHA=${sourceValue('PR_HEAD_SHA')}",
+ "PR_ID=${sourceValue('PR_ID')}",
+ "PR_TITLE=${sourceValue('PR_TITLE') ?: ''}"
]
}
void createGithubCheck() {
- if (params.SOURCE_MODE != 'pull_request' || !hasGithubAppCredential()) {
+ if (sourceValue('SOURCE_MODE') != 'pull_request' || !hasGithubAppCredential()) {
echo 'GitHub Check creation skipped: GitHub App credential is not configured.'
return
}
@@ -394,7 +519,7 @@ void createGithubCheck() {
throw new IllegalStateException("GitHub returned an invalid Check Run ID '${checkRunId}'.")
}
env.GITHUB_CHECK_RUN_ID = checkRunId
- echo "Created GitHub Check Run ${checkRunId} on ${params.PR_HEAD_SHA}."
+ echo "Created GitHub Check Run ${checkRunId} on ${sourceValue('PR_HEAD_SHA')}."
}
}
} catch (Exception e) {
@@ -435,10 +560,10 @@ void completeGithubCheck(String conclusion) {
}
void resolvePresubmitMailRecipient() {
- def webhookEmail = params.PR_AUTHOR_EMAIL?.trim()
+ def webhookEmail = sourceValue('PR_AUTHOR_EMAIL')?.trim()
env.PRESUBMIT_MAIL_TO = webhookEmail?.contains('@') &&
!webhookEmail.equalsIgnoreCase('null') ? webhookEmail : ''
- if (env.PRESUBMIT_MAIL_TO || params.SOURCE_MODE != 'pull_request' ||
+ if (env.PRESUBMIT_MAIL_TO || sourceValue('SOURCE_MODE') != 'pull_request' ||
!hasGithubAppCredential()) {
return
}
@@ -485,14 +610,14 @@ String presubmitInfoRow(String label, String valueHtml) {
}
String presubmitStartHtml() {
- def source = params.SOURCE_MODE == 'branch' ?
- "branch ${params.SOURCE_BRANCH}" : "PR-${params.PR_ID}"
- def sourceHtml = params.PR_URL ==~ /^https:\/\/github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\/pull\/[0-9]+$/ ?
- "${htmlEscape(source)}" :
+ def source = sourceValue('SOURCE_MODE') == 'branch' ?
+ "branch ${params.SOURCE_BRANCH}" : "PR-${sourceValue('PR_ID')}"
+ def sourceHtml = sourceValue('PR_URL') ==~ /^https:\/\/github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\/pull\/[0-9]+$/ ?
+ "${htmlEscape(source)}" :
htmlEscape(source)
def diffNumber = env.PRESUBMIT_DIFF_NUMBER?.trim()
def rows = presubmitInfoRow('Source', sourceHtml) +
- presubmitInfoRow('Title', htmlEscape(params.PR_TITLE ?: 'not provided'))
+ presubmitInfoRow('Title', htmlEscape(sourceValue('PR_TITLE') ?: 'not provided'))
if (diffNumber) {
rows += presubmitInfoRow('Diff', "#${htmlEscape(diffNumber)}")
}
@@ -524,12 +649,13 @@ results follows when this run finishes.
// GitHub App diagnostic skip them, as does a request that `Check eligibility`
// rejected by setting NOT_BUILT.
boolean isWorkerStageEnabled() {
- return params.SOURCE_MODE in ['pull_request', 'branch'] &&
- currentBuild.result != 'NOT_BUILT'
+ return sourceValue('SOURCE_MODE') in ['pull_request', 'branch'] &&
+ currentBuild.result != 'NOT_BUILT' &&
+ currentBuild.currentResult != 'NOT_BUILT'
}
boolean isDiagnoseRun() {
- return params.SOURCE_MODE == 'diagnose'
+ return sourceValue('SOURCE_MODE') == 'diagnose'
}
void discoverPullRequests() {
@@ -668,10 +794,10 @@ void validateSourceRequest() {
TimeZone.getTimeZone('UTC')
)
recordStageEvent('Validate source request', 'STARTED')
- if (!(params.SOURCE_MODE in ['pull_request', 'branch'])) {
- error("Unsupported SOURCE_MODE '${params.SOURCE_MODE}'.")
+ if (!(sourceValue('SOURCE_MODE') in ['pull_request', 'branch'])) {
+ error("Unsupported SOURCE_MODE '${sourceValue('SOURCE_MODE')}'.")
}
- if (params.SOURCE_MODE == 'branch') {
+ if (sourceValue('SOURCE_MODE') == 'branch') {
if (!isValidBranchName(params.SOURCE_BRANCH)) {
error('SOURCE_BRANCH is not a valid Git branch name.')
}
@@ -679,16 +805,16 @@ void validateSourceRequest() {
error('SOURCE_SHA must be a full 40-character Git SHA.')
}
} else {
- if (!(params.PR_ID ==~ /^[0-9]+$/)) {
+ if (!(sourceValue('PR_ID') ==~ /^[0-9]+$/)) {
error('PR_ID must be the numeric GitHub pull request number.')
}
- if (params.PR_REPOSITORY != params.EXPECTED_REPOSITORY) {
- error("Repository '${params.PR_REPOSITORY}' is not '${params.EXPECTED_REPOSITORY}'.")
+ if (sourceValue('PR_REPOSITORY') != params.EXPECTED_REPOSITORY) {
+ error("Repository '${sourceValue('PR_REPOSITORY')}' is not '${params.EXPECTED_REPOSITORY}'.")
}
- if (!(params.PR_HEAD_BRANCH ==~ /^[A-Za-z0-9._\/-]+$/)) {
+ if (!(sourceValue('PR_HEAD_BRANCH') ==~ /^[A-Za-z0-9._\/-]+$/)) {
error('PR_HEAD_BRANCH contains unsupported characters.')
}
- if (!(params.PR_HEAD_SHA ==~ /^[0-9a-fA-F]{40}$/) && params.PR_ACTION != 'closed') {
+ if (!(sourceValue('PR_HEAD_SHA') ==~ /^[0-9a-fA-F]{40}$/) && sourceValue('PR_ACTION') != 'closed') {
error('PR_HEAD_SHA must be a full 40-character Git SHA.')
}
}
@@ -718,15 +844,23 @@ void validateSourceRequest() {
}
}
try {
- def sourceKey = params.SOURCE_MODE == 'branch' ?
- "branch:${params.SOURCE_BRANCH}" :
- "pull_request:${params.PR_ID}"
- env.PRESUBMIT_DIFF_NUMBER = diffNumberForSource(
- env.JOB_NAME,
- env.BUILD_NUMBER.toInteger(),
- sourceKey,
- env.RESOLVED_SOURCE_SHA
- ).toString()
+ if (isMultibranchPullRequest()) {
+ env.PRESUBMIT_DIFF_NUMBER = diffNumberForResolvedSha(
+ env.JOB_NAME,
+ env.BUILD_NUMBER.toInteger(),
+ env.RESOLVED_SOURCE_SHA
+ ).toString()
+ } else {
+ def sourceKey = sourceValue('SOURCE_MODE') == 'branch' ?
+ "branch:${params.SOURCE_BRANCH}" :
+ "pull_request:${sourceValue('PR_ID')}"
+ env.PRESUBMIT_DIFF_NUMBER = diffNumberForSource(
+ env.JOB_NAME,
+ env.BUILD_NUMBER.toInteger(),
+ sourceKey,
+ env.RESOLVED_SOURCE_SHA
+ ).toString()
+ }
} catch (Exception e) {
env.PRESUBMIT_DIFF_NUMBER = ''
echo "Unable to determine the diff number: ${e}"
@@ -736,37 +870,51 @@ void validateSourceRequest() {
// five-minute discovery runs.
def diffLabel = env.PRESUBMIT_DIFF_NUMBER ?
" diff #${env.PRESUBMIT_DIFF_NUMBER}" : ''
- if (params.SOURCE_MODE == 'branch') {
+ if (sourceValue('SOURCE_MODE') == 'branch') {
currentBuild.displayName =
"#${env.BUILD_NUMBER} branch ${params.SOURCE_BRANCH}${diffLabel}"
currentBuild.description = "Branch ${params.SOURCE_BRANCH} @ ${params.SOURCE_SHA.take(12)}"
} else {
currentBuild.displayName =
- "#${env.BUILD_NUMBER} PR-${params.PR_ID}${diffLabel} ${params.PR_ACTION}"
+ "#${env.BUILD_NUMBER} PR-${sourceValue('PR_ID')}${diffLabel} ${sourceValue('PR_ACTION')}"
currentBuild.description =
- "${env.RESOLVED_SOURCE_SHA?.take(12)} ${params.PR_TITLE ?: params.PR_URL}"
+ "${env.RESOLVED_SOURCE_SHA?.take(12)} ${sourceValue('PR_TITLE') ?: sourceValue('PR_URL')}"
}
}
void checkPresubmitEligibility() {
recordStageEvent('Check eligibility', 'STARTED')
- if (params.SOURCE_MODE == 'pull_request') {
+ if (sourceValue('SOURCE_MODE') == 'pull_request') {
def eligibleActions = ['opened', 'synchronize', 'reopened', 'ready_for_review']
- if (params.PR_ACTION == 'closed' ||
- params.PR_DRAFT.toBoolean() ||
- params.PR_BASE_BRANCH != 'main' ||
- !(params.PR_ACTION in eligibleActions)) {
+ if (sourceValue('PR_ACTION') == 'closed' ||
+ sourceValue('PR_DRAFT').toBoolean() ||
+ sourceValue('PR_BASE_BRANCH') != 'main' ||
+ !(sourceValue('PR_ACTION') in eligibleActions)) {
currentBuild.result = 'NOT_BUILT'
- echo "Ignoring PR-${params.PR_ID}: action=${params.PR_ACTION}, draft=${params.PR_DRAFT}, base=${params.PR_BASE_BRANCH}"
+ echo "Ignoring PR-${sourceValue('PR_ID')}: action=${sourceValue('PR_ACTION')}, draft=${sourceValue('PR_DRAFT')}, base=${sourceValue('PR_BASE_BRANCH')}"
}
}
}
void abortSupersededRun() {
recordStageEvent('Abort superseded run', 'STARTED')
- def sourceKey = params.SOURCE_MODE == 'branch' ?
+ if (isMultibranchPullRequest()) {
+ try {
+ supersedeMultibranchBuilds(
+ env.JOB_NAME,
+ env.BUILD_NUMBER.toInteger(),
+ env.RESOLVED_SOURCE_SHA
+ ).each { result ->
+ echo result
+ }
+ } catch (Exception e) {
+ echo "Unable to inspect older builds; no build was aborted: ${e}"
+ }
+ return
+ }
+ def sourceKey = sourceValue('SOURCE_MODE') == 'branch' ?
"branch:${params.SOURCE_BRANCH}" :
- "pull_request:${params.PR_ID}"
+ "pull_request:${sourceValue('PR_ID')}"
try {
abortOlderBuildsForSource(
env.JOB_NAME,
@@ -782,8 +930,110 @@ void abortSupersededRun() {
void checkoutCiScripts() {
recordStageEvent('Checkout CI scripts', 'STARTED')
+ if (env.PRESUBMIT_CI_SHA?.trim() && fileExists('private-cicd/scripts/resolve-pull-request.py')) {
+ echo "CI scripts already checked out from main at ${env.PRESUBMIT_CI_SHA}."
+ return
+ }
deleteDir()
- checkout(scm)
+ def remote = [
+ url: params.CLOUDSTACK_GIT_URL,
+ refspec: '+refs/heads/main:refs/remotes/origin/main'
+ ]
+ def credentialId = params.GIT_CREDENTIALS_ID?.trim()
+ if (!credentialId) {
+ credentialId = params.GITHUB_APP_CREDENTIALS_ID?.trim()
+ }
+ if (credentialId && !credentialId.startsWith('PLACEHOLDER-')) {
+ remote.credentialsId = credentialId
+ }
+ def cloneOptions = [[
+ $class: 'CloneOption',
+ honorRefspec: true,
+ noTags: true,
+ shallow: true,
+ depth: 1
+ ]]
+ try {
+ checkout([
+ $class: 'GitSCM',
+ branches: [[name: '*/main']],
+ doGenerateSubmoduleConfigurations: false,
+ extensions: cloneOptions + [[
+ $class: 'SparseCheckoutPaths',
+ sparseCheckoutPaths: [[path: 'private-cicd']]
+ ]],
+ userRemoteConfigs: [remote]
+ ])
+ } catch (Exception e) {
+ echo "Sparse CI checkout unavailable, using a full main checkout: ${e}"
+ deleteDir()
+ checkout([
+ $class: 'GitSCM',
+ branches: [[name: '*/main']],
+ doGenerateSubmoduleConfigurations: false,
+ extensions: cloneOptions,
+ userRemoteConfigs: [remote]
+ ])
+ }
+ env.PRESUBMIT_CI_SHA = sh(script: 'git rev-parse HEAD', returnStdout: true).trim()
+ echo "Checked out CI scripts from main at ${env.PRESUBMIT_CI_SHA}."
+}
+
+void prepareMultibranchPullRequest() {
+ checkoutCiScripts()
+ def indexed = indexedHeadSha(currentBuild.rawBuild)
+ if (!(indexed ==~ /^[0-9a-fA-F]{40}$/)) {
+ currentBuild.result = 'NOT_BUILT'
+ echo "Not building PR-${env.CHANGE_ID}: Jenkins did not index a 40-character pull request head SHA."
+ return
+ }
+ def status = 1
+ withCredentials([
+ usernamePassword(
+ credentialsId: params.GITHUB_APP_CREDENTIALS_ID,
+ usernameVariable: 'GITHUB_APP',
+ passwordVariable: 'GITHUB_ACCESS_TOKEN'
+ )
+ ]) {
+ withEnv([
+ "PRESUBMIT_REPOSITORY=${params.EXPECTED_REPOSITORY}",
+ "PRESUBMIT_PR_ID=${env.CHANGE_ID}",
+ "PRESUBMIT_INDEXED_SHA=${indexed}",
+ "PRESUBMIT_CHANGE_TARGET=${env.CHANGE_TARGET ?: ''}"
+ ]) {
+ status = sh(
+ script: '''python3 private-cicd/scripts/resolve-pull-request.py \
+ --repository "$PRESUBMIT_REPOSITORY" \
+ --pr-id "$PRESUBMIT_PR_ID" \
+ --indexed-sha "$PRESUBMIT_INDEXED_SHA" \
+ --change-target "$PRESUBMIT_CHANGE_TARGET" \
+ --output multibranch-source.json''',
+ returnStatus: true
+ )
+ }
+ }
+ def decision = fileExists('multibranch-source.json') ? readJSON(file: 'multibranch-source.json') : [:]
+ if (status != 0 || !decision.eligible) {
+ currentBuild.result = 'NOT_BUILT'
+ echo "Not building PR-${env.CHANGE_ID}: ${decision.reason ?: 'pull request lookup failed.'}"
+ return
+ }
+ env.PRESUBMIT_MULTIBRANCH_READY = 'true'
+ env.EFFECTIVE_SOURCE_MODE = 'pull_request'
+ env.EFFECTIVE_PR_ID = decision.pr_id.toString()
+ env.EFFECTIVE_PR_ACTION = 'synchronize'
+ env.EFFECTIVE_PR_DRAFT = 'false'
+ env.EFFECTIVE_PR_REPOSITORY = params.EXPECTED_REPOSITORY
+ env.EFFECTIVE_PR_BASE_BRANCH = decision.pr_base_branch.toString()
+ env.EFFECTIVE_PR_HEAD_BRANCH = decision.pr_head_branch.toString()
+ env.EFFECTIVE_PR_HEAD_SHA = decision.pr_head_sha.toString()
+ env.EFFECTIVE_PR_AUTHOR_LOGIN = decision.pr_author_login?.toString() ?: ''
+ env.EFFECTIVE_PR_AUTHOR_EMAIL = decision.pr_author_email?.toString() ?: ''
+ env.EFFECTIVE_PR_URL = decision.pr_url?.toString() ?: ''
+ env.EFFECTIVE_PR_TITLE = decision.pr_title?.toString() ?: ''
+ env.RESOLVED_SOURCE_SHA = env.EFFECTIVE_PR_HEAD_SHA
+ env.RESOLVED_SOURCE_ID = "pr-${env.EFFECTIVE_PR_ID}"
+ echo "Accepted PR-${env.EFFECTIVE_PR_ID} at ${env.RESOLVED_SOURCE_SHA}."
}
// A full worker run takes over an hour, so reporting problems are diagnosed
@@ -793,11 +1043,11 @@ void diagnoseGithubApp() {
if (!hasGithubAppCredential()) {
error('GITHUB_APP_CREDENTIALS_ID is unset or still a placeholder.')
}
- def headSha = params.PR_HEAD_SHA?.trim() ?: params.SOURCE_SHA?.trim()
+ def headSha = sourceValue('PR_HEAD_SHA')?.trim() ?: params.SOURCE_SHA?.trim()
if (!(headSha ==~ /^[0-9a-f]{40}$/)) {
error('Set PR_HEAD_SHA to a full 40-character commit SHA to diagnose.')
}
- def repository = params.PR_REPOSITORY?.trim() ?: params.EXPECTED_REPOSITORY
+ def repository = sourceValue('PR_REPOSITORY')?.trim() ?: params.EXPECTED_REPOSITORY
withCredentials([
usernamePassword(
credentialsId: params.GITHUB_APP_CREDENTIALS_ID,
@@ -808,7 +1058,7 @@ void diagnoseGithubApp() {
withEnv([
"GITHUB_REPOSITORY=${repository}",
"GITHUB_HEAD_SHA=${headSha}",
- "PR_ID=${params.PR_ID ?: ''}"
+ "PR_ID=${sourceValue('PR_ID') ?: ''}"
]) {
sh 'private-cicd/scripts/github-check-run.sh diagnose'
}
@@ -863,18 +1113,18 @@ void checkoutSource() {
recordStageEvent('Checkout source', 'STARTED')
dir(env.SOURCE_DIR) {
deleteDir()
- def isBranch = params.SOURCE_MODE == 'branch'
- def sourceBranch = isBranch ? params.SOURCE_BRANCH : params.PR_HEAD_BRANCH
- def sourceSha = isBranch ? params.SOURCE_SHA : params.PR_HEAD_SHA
- def sourceLabel = isBranch ? "branch:${sourceBranch}" : "pull_request:${params.PR_ID}"
+ def isBranch = sourceValue('SOURCE_MODE') == 'branch'
+ def sourceBranch = isBranch ? params.SOURCE_BRANCH : sourceValue('PR_HEAD_BRANCH')
+ def sourceSha = isBranch ? params.SOURCE_SHA : sourceValue('PR_HEAD_SHA')
+ def sourceLabel = isBranch ? "branch:${sourceBranch}" : "pull_request:${sourceValue('PR_ID')}"
def remoteRef = isBranch ?
"refs/remotes/origin/source/${sourceBranch}" :
- "refs/remotes/origin/pr/${params.PR_ID}"
+ "refs/remotes/origin/pr/${sourceValue('PR_ID')}"
def remote = [
url: params.CLOUDSTACK_GIT_URL,
refspec: isBranch ?
"+refs/heads/${sourceBranch}:${remoteRef}" :
- "+refs/pull/${params.PR_ID}/head:${remoteRef}"
+ "+refs/pull/${sourceValue('PR_ID')}/head:${remoteRef}"
]
if (params.GIT_CREDENTIALS_ID?.trim()) {
remote.credentialsId = params.GIT_CREDENTIALS_ID
@@ -987,9 +1237,9 @@ void deployAndRunOntapIntegration() {
env.VM_NAME = vm.vcenter_vm as String
env.VM_SNAPSHOT = vm.snapshot as String
env.VM_BRIDGE = vm.bridge as String
- def sourceDescription = params.SOURCE_MODE == 'branch' ?
+ def sourceDescription = sourceValue('SOURCE_MODE') == 'branch' ?
"branch=${params.SOURCE_BRANCH}" :
- "PR-${params.PR_ID}"
+ "PR-${sourceValue('PR_ID')}"
currentBuild.description = "${sourceDescription} VM=${env.VM_ID}"
echo "Reserved compatible VM '${env.VM_ID}' via '${env.LOCKED_VM}'."
@@ -1063,7 +1313,11 @@ void deployAndRunOntapIntegration() {
}
void publishPresubmitResults() {
- if (params.SOURCE_MODE == 'discover') {
+ if (isMultibranchPullRequest() && currentBuild.currentResult == 'NOT_BUILT') {
+ echo "Multibranch PR-${env.CHANGE_ID} was not built."
+ return
+ }
+ if (sourceValue('SOURCE_MODE') == 'discover') {
echo 'Discovery build completed; worker reporting was skipped.'
return
}
@@ -1088,15 +1342,15 @@ void publishPresubmitResults() {
if (!env.PRESUBMIT_MAIL_TO?.trim()) {
resolvePresubmitMailRecipient()
}
- def sourceName = params.SOURCE_MODE == 'branch' ?
- "branch ${params.SOURCE_BRANCH}" : "PR-${params.PR_ID}"
+ def sourceName = sourceValue('SOURCE_MODE') == 'branch' ?
+ "branch ${params.SOURCE_BRANCH}" : "PR-${sourceValue('PR_ID')}"
try {
if (fileExists('private-cicd/scripts/generate-presubmit-report.py')) {
withEnv([
"PRESUBMIT_REPORT_RESULT=${result}",
"PRESUBMIT_REPORT_SOURCE=${sourceName}",
- "PRESUBMIT_REPORT_SOURCE_URL=${params.PR_URL ?: ''}",
- "PRESUBMIT_REPORT_TITLE=${params.PR_TITLE ?: ''}",
+ "PRESUBMIT_REPORT_SOURCE_URL=${sourceValue('PR_URL') ?: ''}",
+ "PRESUBMIT_REPORT_TITLE=${sourceValue('PR_TITLE') ?: ''}",
"PRESUBMIT_REPORT_SHA=${env.RESOLVED_SOURCE_SHA}",
"PRESUBMIT_REPORT_DIFF=${env.PRESUBMIT_DIFF_NUMBER ?: ''}",
"PRESUBMIT_REPORT_VM=${env.VM_ID ?: ''}"
@@ -1193,15 +1447,11 @@ spec:
timeout(time: 8, unit: 'HOURS')
}
- triggers {
- cron('H/5 * * * *')
- }
-
parameters {
choice(
name: 'SOURCE_MODE',
choices: ['discover', 'pull_request', 'branch', 'diagnose'],
- description: 'Timer builds use discover. Self-queued workers use pull_request. Use branch for a manual exact-SHA build, or diagnose to probe GitHub App Checks access using PR_HEAD_SHA.'
+ description: 'Legacy discover mode is not scheduled. Multibranch pull requests ignore this default. Use pull_request for a manual PR build, branch for an exact SHA, or diagnose to probe GitHub App Checks access.'
)
string(name: 'SOURCE_BRANCH', defaultValue: '', description: 'Manual branch mode: remote branch name, for example feature/CSTACKEX-223.')
string(name: 'SOURCE_SHA', defaultValue: '', description: 'Manual branch mode: exact full 40-character commit SHA.')
@@ -1260,7 +1510,7 @@ spec:
stage('Discover PR revisions') {
when {
expression {
- return params.SOURCE_MODE == 'discover'
+ return sourceValue('SOURCE_MODE') == 'discover' && !isMultibranchPullRequest()
}
}
steps {
@@ -1270,6 +1520,19 @@ spec:
}
}
+ stage('Prepare multibranch pull request') {
+ when {
+ expression {
+ return isMultibranchPullRequest()
+ }
+ }
+ steps {
+ script {
+ prepareMultibranchPullRequest()
+ }
+ }
+ }
+
stage('Validate source request') {
when {
expression {
diff --git a/private-cicd/README.md b/private-cicd/README.md
index ae48b35a9a75..df978fd69ddf 100644
--- a/private-cicd/README.md
+++ b/private-cicd/README.md
@@ -28,10 +28,11 @@ current implementation contract, see
## How it works
-The production Jenkins job loads [`Jenkinsfile`](Jenkinsfile) and helper scripts
-from the protected NetApp `main` branch. Every five minutes a short discovery
-build polls GitHub and self-queues a worker for each unseen PR head SHA. Each
-worker checks out the exact commit into a separate `cloudstack-src` directory,
+The production job is the multibranch folder `cloudstack-ontap-presubmit-mb`.
+Jenkins scans GitHub about once a minute. That scan is not a build. Each
+non-draft origin pull request into `main` gets its own child job. The child
+loads [`Jenkinsfile`](Jenkinsfile) and helper scripts from protected `main`,
+then checks the exact pull-request head SHA out into `cloudstack-src`. A child
then:
1. validates the request, builder, credentials, and VM inventory;
@@ -48,10 +49,10 @@ replacing the underlying build or test result.
## Entry points
-- **Pull request:** `SOURCE_MODE=discover` notices an open non-draft PR update
- to `main` and self-queues at most five `SOURCE_MODE=pull_request` workers in
- the same job. Extra SHAs wait for a later poll. Workers then wait on the
- `cloudstack-presubmit-vm` lock for a lab VM.
+- **Pull request:** a folder scan creates one child job per open non-draft
+ origin pull request into `main`. Drafts get no job. Compiles run together.
+ Integration waits on the `cloudstack-presubmit-vm` lock. There is no
+ five-worker cap. GitHub cannot call this Jenkins, so there is no webhook.
- **Manual branch:** a separate triggerless job uses `SOURCE_MODE=branch` with
a remote branch and exact 40-character commit SHA.
- **Local validation:** `scripts/validate-local.sh` checks the CI files without
diff --git a/private-cicd/docs/CREATE-PRESUBMIT-JOB.md b/private-cicd/docs/CREATE-PRESUBMIT-JOB.md
index c3ce8623a945..9e1b46871e85 100644
--- a/private-cicd/docs/CREATE-PRESUBMIT-JOB.md
+++ b/private-cicd/docs/CREATE-PRESUBMIT-JOB.md
@@ -19,12 +19,18 @@
# Create the auto-triggered ONTAP presubmit job
-This guide creates **one** Pipeline job, `cloudstack-ontap-presubmit`. Timer
-builds poll GitHub every five minutes and self-queue one worker build for each
-new eligible PR head SHA. Worker builds compile and package concurrently, then
-wait for an available `cloudstack-presubmit-vm` Lockable Resource before
-integration. Until `feature/CSTACKEX-223` is merged, the job loads trusted CI
-from that branch. After merge, change only **Branches to build** to `*/main`.
+The current job is the multibranch folder `cloudstack-ontap-presubmit-mb`.
+It scans GitHub about once a minute and creates one child job for every open
+non-draft origin pull request into `main`. Remote Jenkinsfile Provider loads
+`private-cicd/Jenkinsfile` from `main`. Match branches stays off. Draft pull
+requests get no job. The folder include filter can be narrowed to one `PR-`
+for the first run, then returned to `PR-*`. Do not add a webhook.
+
+The sections below describe the retired single Pipeline job and its five-minute
+`SOURCE_MODE=discover` timer. Do not create that second job. Keep
+`cloudstack-ontap-presubmit` disabled after the folder works. Do not delete the
+GitHub App, credentials, or the VM lock until a multibranch pull request has
+passed.
Do not create a GitHub repository webhook. OpenLab Jenkins is not reachable
from GitHub.com, so inbound `pull_request` deliveries fail with
@@ -36,8 +42,8 @@ item for this presubmit: that would run `Jenkinsfile` from the PR branch. This
design loads trusted CI from a fixed branch and checks the PR SHA out
separately.
-Do not create a separate poller, Multibranch Pipeline, or second production
-copy of this job.
+The multibranch folder above is the production job. Do not create another
+poller or a second production copy.
This guide starts before the Jenkins item exists and ends after polling and
required Check have been verified. Follow sections 1 through 21 in order. Do
diff --git a/private-cicd/docs/PRIVATE-CICD-GUIDE.md b/private-cicd/docs/PRIVATE-CICD-GUIDE.md
index a5617af9e47a..367408093e84 100644
--- a/private-cicd/docs/PRIVATE-CICD-GUIDE.md
+++ b/private-cicd/docs/PRIVATE-CICD-GUIDE.md
@@ -30,9 +30,9 @@ This is the consolidated operator and developer reference. The current [`Jenkins
The guide covers:
-- the trusted production Pipeline-from-SCM job with five-minute GitHub API
- discovery, including first-PR cutover from `feature/CSTACKEX-223` to `main`
- ([`CREATE-PRESUBMIT-JOB.md`](CREATE-PRESUBMIT-JOB.md));
+- the multibranch folder `cloudstack-ontap-presubmit-mb`, which scans about
+ once a minute and runs trusted CI from `main` for every non-draft origin
+ pull request into `main` ([`CREATE-PRESUBMIT-JOB.md`](CREATE-PRESUBMIT-JOB.md));
- the separate triggerless manual branch job;
- local CI-file validation and direct disposable-VM validation;
- Stage 1 build, unit-test, and Debian package handoff;
@@ -68,12 +68,10 @@ VM lock.
## 3. Architecture and data flow
```text
-Jenkins timer -> discover mode (every 5 minutes)
- -> GitHub API -> unseen PR head SHAs
- -> self-queue at most MAX_ACTIVE_WORKERS pull_request builds
- -> extra SHAs deferred with watermark held
- -> self-queued worker builds in the same job
- -> trusted Jenkinsfile/scripts from protected main
+Folder scan about once a minute (not a pipeline build)
+ -> one child job per open non-draft origin PR into main
+ -> trusted Jenkinsfile/scripts from protected main
+ -> exact indexed PR head SHA, confirmed with the pull request API
-> request, eligibility, credential, inventory, and builder checks
-> exact PR or branch SHA checkout into cloudstack-src
-> Maven clean install and unit tests
@@ -156,12 +154,10 @@ Lightweight checkout: off
PR code cannot replace trusted CI helpers before credentials are used. The source under test is checked out
separately.
-Until `feature/CSTACKEX-223` is merged, that Jenkinsfile is not on `main`. Create one job named
-`cloudstack-ontap-presubmit` that loads CI from `*/feature/CSTACKEX-223`, using
-[`CREATE-PRESUBMIT-JOB.md`](CREATE-PRESUBMIT-JOB.md). Its timer builds discover work and self-queue workers in the
-same item. After merge, change only **Branches to build** to `*/main`. Keep the same job, GitHub App, credentials,
-resources, and Check. Do not create a GitHub repository webhook: OpenLab Jenkins is not reachable from GitHub.com.
-Do not create a GitHub Organization folder for this presubmit.
+Production is the multibranch folder `cloudstack-ontap-presubmit-mb`. Remote Jenkinsfile
+Provider loads this Jenkinsfile from `main` with Match branches off. A folder scan,
+not a webhook, finds pull requests. Keep the old `cloudstack-ontap-presubmit` job
+disabled. Do not create a GitHub repository webhook or a GitHub Organization folder.
### Manual-job trust boundary
diff --git a/private-cicd/scripts/resolve-pull-request.py b/private-cicd/scripts/resolve-pull-request.py
new file mode 100644
index 000000000000..9430b1ddea58
--- /dev/null
+++ b/private-cicd/scripts/resolve-pull-request.py
@@ -0,0 +1,133 @@
+#!/usr/bin/env python3
+# 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.
+
+"""Decide whether a multibranch build may test one origin pull request."""
+
+import argparse
+import json
+import os
+import sys
+import urllib.error
+import urllib.request
+
+
+API_ROOT = "https://api.github.com"
+
+
+def parse_args():
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--repository", required=True)
+ parser.add_argument("--pr-id", required=True)
+ parser.add_argument("--indexed-sha", required=True)
+ parser.add_argument("--change-target", required=True)
+ parser.add_argument("--output", required=True)
+ return parser.parse_args()
+
+
+def github_get(url, token):
+ request = urllib.request.Request(
+ url,
+ headers={
+ "Accept": "application/vnd.github+json",
+ "Authorization": f"Bearer {token}",
+ "X-GitHub-Api-Version": "2022-11-28",
+ "User-Agent": "cloudstack-ontap-presubmit",
+ },
+ )
+ try:
+ with urllib.request.urlopen(request, timeout=60) as response:
+ return json.loads(response.read().decode("utf-8"))
+ except urllib.error.HTTPError as error:
+ detail = error.read().decode("utf-8", errors="replace")
+ raise SystemExit(f"GitHub API {error.code} for {url}: {detail}") from error
+
+
+def is_main(ref):
+ return ref in ("main", "refs/heads/main")
+
+
+def assess(pull, pr_id, indexed_sha, change_target, expected_repository):
+ head = (pull or {}).get("head") or {}
+ base = (pull or {}).get("base") or {}
+ user = (pull or {}).get("user") or {}
+ repository = head.get("repo") or {}
+ decision = {
+ "eligible": False,
+ "reason": "",
+ "pr_id": str((pull or {}).get("number") or ""),
+ "pr_head_sha": head.get("sha") or "",
+ "pr_head_branch": head.get("ref") or "",
+ "pr_base_branch": base.get("ref") or "",
+ "pr_draft": bool((pull or {}).get("draft")),
+ "pr_title": (pull or {}).get("title") or "",
+ "pr_url": (pull or {}).get("html_url") or "",
+ "pr_author_login": user.get("login") or "",
+ "pr_author_email": user.get("email") or "",
+ "head_repository": repository.get("full_name") or "",
+ }
+ if not isinstance(pull, dict):
+ decision["reason"] = "GitHub pull request payload is missing."
+ elif decision["pr_id"] != str(pr_id):
+ decision["reason"] = "GitHub pull request number does not match the build."
+ elif decision["head_repository"] != expected_repository:
+ decision["reason"] = (
+ f"head repository '{decision['head_repository']}' is not "
+ f"'{expected_repository}'."
+ )
+ elif not is_main(decision["pr_base_branch"]) or not is_main(change_target):
+ decision["reason"] = (
+ f"base branch is '{decision['pr_base_branch']}' and change target "
+ f"is '{change_target}'."
+ )
+ elif decision["pr_draft"]:
+ decision["reason"] = "pull request is a draft."
+ elif decision["pr_head_sha"].lower() != (indexed_sha or "").lower():
+ decision["reason"] = "indexed revision does not match the pull request head SHA."
+ else:
+ decision["eligible"] = True
+ return decision
+
+
+def main():
+ args = parse_args()
+ token = os.environ.get("GITHUB_ACCESS_TOKEN", "").strip()
+ if not token:
+ raise SystemExit("GITHUB_ACCESS_TOKEN is empty.")
+ if not args.pr_id.isdigit():
+ raise SystemExit("PR id must be numeric.")
+ pull = github_get(
+ f"{API_ROOT}/repos/{args.repository}/pulls/{args.pr_id}",
+ token,
+ )
+ decision = assess(
+ pull,
+ args.pr_id,
+ args.indexed_sha,
+ args.change_target,
+ args.repository,
+ )
+ with open(args.output, "w", encoding="utf-8") as handle:
+ json.dump(decision, handle)
+ handle.write("\n")
+ if not decision["eligible"]:
+ print(decision["reason"], file=sys.stderr)
+ raise SystemExit(2)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/private-cicd/scripts/test_resolve_pull_request.py b/private-cicd/scripts/test_resolve_pull_request.py
new file mode 100644
index 000000000000..ca1a6b403e47
--- /dev/null
+++ b/private-cicd/scripts/test_resolve_pull_request.py
@@ -0,0 +1,82 @@
+#!/usr/bin/env python3
+# 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.
+
+"""Unit tests for resolve-pull-request.py."""
+
+import importlib.util
+import unittest
+from pathlib import Path
+
+
+SCRIPT = Path(__file__).with_name("resolve-pull-request.py")
+SPEC = importlib.util.spec_from_file_location("resolve_pull_request", SCRIPT)
+MODULE = importlib.util.module_from_spec(SPEC)
+SPEC.loader.exec_module(MODULE)
+
+SHA = "a" * 40
+
+
+def pull(number="98", draft=False, base="main", repo="NetApp/cloudstack", sha=SHA):
+ return {
+ "number": int(number),
+ "title": "Test",
+ "html_url": f"https://github.com/NetApp/cloudstack/pull/{number}",
+ "draft": draft,
+ "base": {"ref": base},
+ "head": {
+ "ref": "feature/test",
+ "sha": sha,
+ "repo": {"full_name": repo} if repo is not None else None,
+ },
+ "user": {"login": "tester", "email": None},
+ }
+
+
+class AssessPullRequestTest(unittest.TestCase):
+ def test_accepts_matching_origin_pr(self):
+ decision = MODULE.assess(pull(), "98", SHA, "main", "NetApp/cloudstack")
+
+ self.assertTrue(decision["eligible"])
+ self.assertEqual("", decision["reason"])
+ self.assertEqual("tester", decision["pr_author_login"])
+ self.assertEqual("", decision["pr_author_email"])
+
+ def test_rejects_draft_fork_base_and_sha_mismatch(self):
+ cases = [
+ pull(draft=True),
+ pull(repo="other/cloudstack"),
+ pull(repo=None),
+ pull(base="release"),
+ pull(sha="b" * 40),
+ ]
+
+ for candidate in cases:
+ decision = MODULE.assess(
+ candidate, "98", SHA, "main", "NetApp/cloudstack")
+ self.assertFalse(decision["eligible"], candidate)
+
+ def test_rejects_change_target_other_than_main(self):
+ decision = MODULE.assess(
+ pull(), "98", SHA, "feature/other", "NetApp/cloudstack")
+
+ self.assertFalse(decision["eligible"])
+ self.assertIn("change target", decision["reason"])
+
+
+if __name__ == "__main__":
+ unittest.main()
From 72bcea35c7b412810218eda3a365a5bb4156ac4b Mon Sep 17 00:00:00 2001
From: sandeeplocharla
Date: Tue, 13 Oct 2026 09:18:59 +0530
Subject: [PATCH 2/5] Check CI scripts out of the PR branch for the presubmit
pilot.
The new pull-request script is not on main yet, so the PR-119 trial has to load it from feature/CSTACKEX-340. Change this back to main before the folder builds every pull request.
---
private-cicd/Jenkinsfile | 14 ++++++++------
1 file changed, 8 insertions(+), 6 deletions(-)
diff --git a/private-cicd/Jenkinsfile b/private-cicd/Jenkinsfile
index 396154123ba6..92b52c57809a 100644
--- a/private-cicd/Jenkinsfile
+++ b/private-cicd/Jenkinsfile
@@ -930,14 +930,16 @@ void abortSupersededRun() {
void checkoutCiScripts() {
recordStageEvent('Checkout CI scripts', 'STARTED')
+ // Pilot for PR-119. Change this back to main before the folder includes every PR.
+ def ciBranch = 'feature/CSTACKEX-340'
if (env.PRESUBMIT_CI_SHA?.trim() && fileExists('private-cicd/scripts/resolve-pull-request.py')) {
- echo "CI scripts already checked out from main at ${env.PRESUBMIT_CI_SHA}."
+ echo "CI scripts already checked out from ${ciBranch} at ${env.PRESUBMIT_CI_SHA}."
return
}
deleteDir()
def remote = [
url: params.CLOUDSTACK_GIT_URL,
- refspec: '+refs/heads/main:refs/remotes/origin/main'
+ refspec: "+refs/heads/${ciBranch}:refs/remotes/origin/${ciBranch}"
]
def credentialId = params.GIT_CREDENTIALS_ID?.trim()
if (!credentialId) {
@@ -956,7 +958,7 @@ void checkoutCiScripts() {
try {
checkout([
$class: 'GitSCM',
- branches: [[name: '*/main']],
+ branches: [[name: "*/${ciBranch}"]],
doGenerateSubmoduleConfigurations: false,
extensions: cloneOptions + [[
$class: 'SparseCheckoutPaths',
@@ -965,18 +967,18 @@ void checkoutCiScripts() {
userRemoteConfigs: [remote]
])
} catch (Exception e) {
- echo "Sparse CI checkout unavailable, using a full main checkout: ${e}"
+ echo "Sparse CI checkout unavailable, using a full ${ciBranch} checkout: ${e}"
deleteDir()
checkout([
$class: 'GitSCM',
- branches: [[name: '*/main']],
+ branches: [[name: "*/${ciBranch}"]],
doGenerateSubmoduleConfigurations: false,
extensions: cloneOptions,
userRemoteConfigs: [remote]
])
}
env.PRESUBMIT_CI_SHA = sh(script: 'git rev-parse HEAD', returnStdout: true).trim()
- echo "Checked out CI scripts from main at ${env.PRESUBMIT_CI_SHA}."
+ echo "Checked out CI scripts from ${ciBranch} at ${env.PRESUBMIT_CI_SHA}."
}
void prepareMultibranchPullRequest() {
From 733a0ed8863a1c3298ac61b72d9c057e1b8dde6b Mon Sep 17 00:00:00 2001
From: sandeeplocharla
Date: Tue, 13 Oct 2026 10:25:14 +0530
Subject: [PATCH 3/5] Look up the indexed pull request commit without opening
the raw build.
Jenkins warns that approving RunWrapper.getRawBuild lets a pipeline step outside the script sandbox. The existing job lookup already used for older builds can read the same revision.
---
private-cicd/Jenkinsfile | 6 ++++--
1 file changed, 4 insertions(+), 2 deletions(-)
diff --git a/private-cicd/Jenkinsfile b/private-cicd/Jenkinsfile
index 92b52c57809a..cd90f072d48c 100644
--- a/private-cicd/Jenkinsfile
+++ b/private-cicd/Jenkinsfile
@@ -163,7 +163,9 @@ String revisionHeadSha(revision) {
}
@NonCPS
-String indexedHeadSha(build) {
+String indexedHeadSha(String jobName, int buildNumber) {
+ def job = jenkins.model.Jenkins.get().getItemByFullName(jobName)
+ def build = job?.getBuildByNumber(buildNumber)
return revisionHeadSha(build?.getAction(jenkins.scm.api.SCMRevisionAction)?.revision)
}
@@ -983,7 +985,7 @@ void checkoutCiScripts() {
void prepareMultibranchPullRequest() {
checkoutCiScripts()
- def indexed = indexedHeadSha(currentBuild.rawBuild)
+ def indexed = indexedHeadSha(env.JOB_NAME, env.BUILD_NUMBER.toInteger())
if (!(indexed ==~ /^[0-9a-fA-F]{40}$/)) {
currentBuild.result = 'NOT_BUILT'
echo "Not building PR-${env.CHANGE_ID}: Jenkins did not index a 40-character pull request head SHA."
From 24fbfa54d38e724be46e73a1fd086c03c0d22a4e Mon Sep 17 00:00:00 2001
From: sandeeplocharla
Date: Tue, 13 Oct 2026 10:49:34 +0530
Subject: [PATCH 4/5] Read the indexed pull request commit from the multibranch
job.
A manual build does not copy the scan revision onto the build, so the prepare stage treated a real PR as unindexed.
---
private-cicd/Jenkinsfile | 10 +++++++++-
1 file changed, 9 insertions(+), 1 deletion(-)
diff --git a/private-cicd/Jenkinsfile b/private-cicd/Jenkinsfile
index cd90f072d48c..37341030941c 100644
--- a/private-cicd/Jenkinsfile
+++ b/private-cicd/Jenkinsfile
@@ -165,7 +165,15 @@ String revisionHeadSha(revision) {
@NonCPS
String indexedHeadSha(String jobName, int buildNumber) {
def job = jenkins.model.Jenkins.get().getItemByFullName(jobName)
- def build = job?.getBuildByNumber(buildNumber)
+ if (job == null) {
+ return ''
+ }
+ // A manual build does not copy the indexed revision onto the build. The scan stores it on the job.
+ def hash = revisionHeadSha(job.getAction(jenkins.scm.api.SCMRevisionAction)?.revision)
+ if (hash) {
+ return hash
+ }
+ def build = job.getBuildByNumber(buildNumber)
return revisionHeadSha(build?.getAction(jenkins.scm.api.SCMRevisionAction)?.revision)
}
From bcd52efcfe7df8426bb01be312b803f4162d9fd4 Mon Sep 17 00:00:00 2001
From: sandeeplocharla
Date: Tue, 13 Oct 2026 10:59:32 +0530
Subject: [PATCH 5/5] Use the GitHub pull request head when Jenkins exposes no
indexed SHA.
The multibranch job never attaches a pull request revision, so refusing the build there skipped the GitHub eligibility check.
---
private-cicd/Jenkinsfile | 5 ++---
private-cicd/scripts/resolve-pull-request.py | 4 +++-
private-cicd/scripts/test_resolve_pull_request.py | 6 ++++++
3 files changed, 11 insertions(+), 4 deletions(-)
diff --git a/private-cicd/Jenkinsfile b/private-cicd/Jenkinsfile
index 37341030941c..13ba47909786 100644
--- a/private-cicd/Jenkinsfile
+++ b/private-cicd/Jenkinsfile
@@ -995,9 +995,8 @@ void prepareMultibranchPullRequest() {
checkoutCiScripts()
def indexed = indexedHeadSha(env.JOB_NAME, env.BUILD_NUMBER.toInteger())
if (!(indexed ==~ /^[0-9a-fA-F]{40}$/)) {
- currentBuild.result = 'NOT_BUILT'
- echo "Not building PR-${env.CHANGE_ID}: Jenkins did not index a 40-character pull request head SHA."
- return
+ indexed = ''
+ echo "Jenkins did not expose an indexed head SHA for PR-${env.CHANGE_ID}. Using the GitHub pull request head."
}
def status = 1
withCredentials([
diff --git a/private-cicd/scripts/resolve-pull-request.py b/private-cicd/scripts/resolve-pull-request.py
index 9430b1ddea58..01f90de0a50a 100644
--- a/private-cicd/scripts/resolve-pull-request.py
+++ b/private-cicd/scripts/resolve-pull-request.py
@@ -96,7 +96,9 @@ def assess(pull, pr_id, indexed_sha, change_target, expected_repository):
)
elif decision["pr_draft"]:
decision["reason"] = "pull request is a draft."
- elif decision["pr_head_sha"].lower() != (indexed_sha or "").lower():
+ elif not decision["pr_head_sha"] or len(decision["pr_head_sha"]) != 40:
+ decision["reason"] = "GitHub pull request head SHA is missing."
+ elif (indexed_sha or "").strip() and decision["pr_head_sha"].lower() != indexed_sha.strip().lower():
decision["reason"] = "indexed revision does not match the pull request head SHA."
else:
decision["eligible"] = True
diff --git a/private-cicd/scripts/test_resolve_pull_request.py b/private-cicd/scripts/test_resolve_pull_request.py
index ca1a6b403e47..739fe10a89e0 100644
--- a/private-cicd/scripts/test_resolve_pull_request.py
+++ b/private-cicd/scripts/test_resolve_pull_request.py
@@ -70,6 +70,12 @@ def test_rejects_draft_fork_base_and_sha_mismatch(self):
candidate, "98", SHA, "main", "NetApp/cloudstack")
self.assertFalse(decision["eligible"], candidate)
+ def test_accepts_github_head_when_jenkins_sha_is_missing(self):
+ decision = MODULE.assess(pull(), "98", "", "main", "NetApp/cloudstack")
+
+ self.assertTrue(decision["eligible"])
+ self.assertEqual(SHA, decision["pr_head_sha"])
+
def test_rejects_change_target_other_than_main(self):
decision = MODULE.assess(
pull(), "98", SHA, "feature/other", "NetApp/cloudstack")