pipeline { agent { docker { image 'mcr.microsoft.com/dotnet/sdk:9.0' args '-u root:root' } } options { ansiColor('xterm') timestamps() buildDiscarder(logRotator(numToKeepStr: '15')) disableConcurrentBuilds() skipDefaultCheckout(true) } parameters { string(name: 'GIT_REPO_URL', defaultValue: 'file:///SourceCode/repos/AS400API.git', description: 'Git repository URL') string(name: 'GIT_CREDENTIALS_ID', defaultValue: '', description: 'Credentials ID for Git operations (required for remote repos)') string(name: 'SONARQUBE_SERVER', defaultValue: '', description: 'Name of the configured SonarQube server in Jenkins (Manage Jenkins > Configure System)') string(name: 'SONAR_TOKEN_ID', defaultValue: '', description: 'Secret Text credential ID that stores the SonarQube token') string(name: 'SONAR_ORG', defaultValue: '', description: 'Optional SonarQube organization key') string(name: 'BUILD_CONFIGURATION', defaultValue: 'Release', description: 'dotnet build configuration') } environment { DOTNET_CLI_TELEMETRY_OPTOUT = '1' DOTNET_SKIP_FIRST_TIME_EXPERIENCE = '1' DOTNET_NOLOGO = '1' COVERAGE_OUTPUT = 'TestResults/Coverage' COVERAGE_REPORT_DIR = 'TestResults/Coverage/report' PUBLISH_OUTPUT = 'publish/Release' PATH = "${env.PATH}:/root/.dotnet/tools" SONAR_PROJECT_KEY = 'AS400API' } stages { stage('Checkout') { steps { script { if (!params.GIT_REPO_URL?.trim()) { error 'GIT_REPO_URL is required.' } echo '=== Checkout Source ===' String desiredBranch = env.CHANGE_BRANCH ?: env.BRANCH_NAME ?: 'main' if (!desiredBranch.startsWith('*/')) { desiredBranch = "*/${desiredBranch}" } def remoteConfig = [url: params.GIT_REPO_URL.trim()] if (params.GIT_CREDENTIALS_ID?.trim()) { remoteConfig.credentialsId = params.GIT_CREDENTIALS_ID.trim() } checkout([ $class : 'GitSCM', branches : [[name: desiredBranch]], doGenerateSubmoduleConfigurations: false, extensions : [[$class: 'CloneOption', depth: 0, noTags: false, shallow: false]], userRemoteConfigs : [remoteConfig] ]) } } } stage('Environment Prep') { steps { sh '''#!/bin/bash -eo pipefail echo '=== Install runtime dependencies ===' apt-get update apt-get install -y --no-install-recommends openjdk-17-jre rm -rf /var/lib/apt/lists/* echo '=== Verify dotnet CLI ===' dotnet --info echo '=== Install global .NET tools ===' for tool in dotnet-sonarscanner dotnet-reportgenerator-globaltool; do dotnet tool install --global "$tool" || dotnet tool update --global "$tool" done ''' } } stage('Restore') { steps { sh '''#!/bin/bash -eo pipefail echo '=== dotnet restore ===' dotnet restore AS400API.sln ''' } } stage('SCA - Dependencies') { steps { script { sh '''#!/bin/bash -eo pipefail echo '=== NuGet vulnerability scan ===' mkdir -p security-reports dotnet list AS400API.sln package --vulnerable --include-transitive | tee security-reports/nuget-vulnerabilities.txt ''' def nugetReport = readFile('security-reports/nuget-vulnerabilities.txt') if (nugetReport.contains('Severity: Critical')) { error 'Critical vulnerabilities detected in NuGet dependencies.' } else if (nugetReport.contains('Severity: ') || nugetReport.contains('Vulnerable packages found')) { unstable 'NuGet vulnerabilities detected (no critical findings).' } else { echo 'No vulnerable NuGet packages reported.' } if (sh(script: 'command -v dependency-check.sh >/dev/null 2>&1', returnStatus: true) == 0) { echo '=== OWASP Dependency-Check ===' catchError(buildResult: 'UNSTABLE', stageResult: 'UNSTABLE') { sh '''#!/bin/bash -eo pipefail dependency-check.sh --project "AS400API" --scan "." --format "HTML" --out "dependency-check-report" --failOnCVSS 9 ''' } } else { echo 'OWASP Dependency-Check CLI not available on this agent; skipping (set up dependency-check.sh to enable).' } } } } stage('SonarQube Begin') { when { not { branch 'dependabot/**' } } steps { script { if (!params.SONARQUBE_SERVER?.trim()) { error 'SONARQUBE_SERVER parameter must be provided.' } if (!params.SONAR_TOKEN_ID?.trim()) { error 'SONAR_TOKEN_ID parameter must be provided.' } String sonarOrgArg = params.SONAR_ORG?.trim() ? "/o:${params.SONAR_ORG.trim()}" : '' withCredentials([string(credentialsId: params.SONAR_TOKEN_ID, variable: 'SONAR_TOKEN')]) { withSonarQubeEnv(params.SONARQUBE_SERVER.trim()) { sh """#!/bin/bash -eo pipefail echo '=== SonarQube begin ===' dotnet sonarscanner begin /k:"${SONAR_PROJECT_KEY}" ${sonarOrgArg} /d:sonar.host.url="${SONAR_HOST_URL}" /d:sonar.login="${SONAR_TOKEN}" /d:sonar.cs.opencover.reportsPaths="${COVERAGE_OUTPUT}/coverage.opencover.xml" /d:sonar.coverageReportPaths="${COVERAGE_OUTPUT}/coverage.opencover.xml" """ } } } } } stage('Unit Test + Coverage') { steps { sh '''#!/bin/bash -eo pipefail echo '=== Run unit tests with coverage ===' rm -rf "${COVERAGE_OUTPUT}" mkdir -p "${COVERAGE_OUTPUT}" dotnet test AS400API.sln --configuration "${BUILD_CONFIGURATION}" /p:CollectCoverage=true /p:CoverletOutputFormat=opencover /p:CoverletOutput="${COVERAGE_OUTPUT}/" --logger "trx;LogFileName=test-results.trx" --logger "junit;LogFileName=test-results.xml" echo '=== Generate coverage report ===' reportgenerator -reports:"${COVERAGE_OUTPUT}/coverage.opencover.xml" -targetdir:"${COVERAGE_REPORT_DIR}" -reporttypes:"Html;Cobertura" ''' junit testResults: 'TestResults/**/*.xml', allowEmptyResults: false, healthScaleFactor: 1.0 } } stage('SonarQube End & Quality Gate') { when { not { branch 'dependabot/**' } } steps { script { withCredentials([string(credentialsId: params.SONAR_TOKEN_ID, variable: 'SONAR_TOKEN')]) { withSonarQubeEnv(params.SONARQUBE_SERVER.trim()) { sh '''#!/bin/bash -eo pipefail echo '=== SonarQube end ===' dotnet sonarscanner end /d:sonar.login="${SONAR_TOKEN}" ''' } } timeout(time: 10, unit: 'MINUTES') { def qualityGate = waitForQualityGate() if (qualityGate.status != 'OK') { error "Pipeline aborted due to SonarQube Quality Gate failure: ${qualityGate.status}" } } } } } stage('Build/Publish') { steps { sh '''#!/bin/bash -eo pipefail echo '=== dotnet publish ===' rm -rf "${PUBLISH_OUTPUT}" dotnet publish AS400API.sln --configuration "${BUILD_CONFIGURATION}" --output "${PUBLISH_OUTPUT}" ''' } } stage('Main Branch Release Prep') { when { branch 'main' } steps { echo 'Main branch detected: running release-specific validations.' sh '''#!/bin/bash -eo pipefail echo '=== Main branch: verifying publish directory contents ===' test -d "${PUBLISH_OUTPUT}" find "${PUBLISH_OUTPUT}" -maxdepth 2 -type f -print ''' } } stage('Package & Archive Artifacts') { steps { script { String shortCommit = (env.GIT_COMMIT ?: 'manual').take(8) String artifactRoot = "artifacts/AS400API-${env.BUILD_NUMBER}-${shortCommit}" env.PIPELINE_ARTIFACT_ROOT = artifactRoot sh """#!/bin/bash -eo pipefail echo '=== Prepare artifacts ===' rm -rf artifacts mkdir -p "${artifactRoot}" mkdir -p "${artifactRoot}/reports" if [ -d "${PUBLISH_OUTPUT}" ]; then cp -R "${PUBLISH_OUTPUT}" "${artifactRoot}/publish" fi if [ -d "${COVERAGE_REPORT_DIR}" ]; then cp -R "${COVERAGE_REPORT_DIR}" "${artifactRoot}/reports/coverage-html" fi if [ -f "${COVERAGE_OUTPUT}/coverage.opencover.xml" ]; then cp "${COVERAGE_OUTPUT}/coverage.opencover.xml" "${artifactRoot}/reports/coverage.opencover.xml" fi if [ -d "security-reports" ]; then cp -R "security-reports" "${artifactRoot}/reports/security" fi if [ -d "dependency-check-report" ]; then cp -R "dependency-check-report" "${artifactRoot}/reports/dependency-check" fi """ publishHTML(target: [ allowMissing : true, alwaysLinkToLastBuild: true, keepAll : true, reportDir : "${COVERAGE_REPORT_DIR}", reportFiles : 'index.html', reportName : 'Code Coverage' ]) archiveArtifacts artifacts: "${artifactRoot}/**", fingerprint: true, allowEmptyArchive: false } } } } post { always { echo '=== Post-build: archiving logs ===' archiveArtifacts artifacts: 'Logs/**/*.log', allowEmptyArchive: true } success { echo 'Build finished successfully.' } unstable { echo 'Build marked unstable due to reported issues.' } failure { echo 'Build failed. Review stage logs above for details.' } } }