diff --git a/Jenkinsfile b/Jenkinsfile index 65bf144..c8cad51 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -3,7 +3,6 @@ pipeline { docker { image 'mcr.microsoft.com/dotnet/sdk:9.0' args '-u root:root' - label "${params.AGENT_LABEL?.trim() ?: 'docker'}" } } @@ -16,267 +15,171 @@ pipeline { } 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') - string(name: 'AGENT_LABEL', defaultValue: 'docker', description: 'Agent label that has Docker CLI available') + string(name: 'GIT_URL', defaultValue: 'file:///SourceCode/repos/AS400API.git', description: 'Git repository URL to clone') + string(name: 'GIT_BRANCH', defaultValue: 'main', description: 'Branch or ref to build') } 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" + BUILD_CONFIGURATION = 'Release' + TEST_RESULTS_DIR = 'TestResults' + COVERAGE_DIR = 'TestResults/Coverage' + COVERAGE_FILE = 'TestResults/Coverage/coverage.opencover.xml' + PUBLISH_DIR = 'publish/Release' SONAR_PROJECT_KEY = 'AS400API' + SONAR_PROJECT_NAME = 'AS400API' } stages { stage('Checkout') { steps { + // Fetch the requested branch from the configured Git URL. script { - if (!params.GIT_REPO_URL?.trim()) { - error 'GIT_REPO_URL is required.' + if (!params.GIT_URL?.trim()) { + error 'GIT_URL parameter must not be empty.' } - 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() + String branchSpec = params.GIT_BRANCH?.trim() ?: 'main' + if (!branchSpec.startsWith('*/')) { + branchSpec = "*/${branchSpec}" } checkout([ $class : 'GitSCM', - branches : [[name: desiredBranch]], + branches : [[name: branchSpec]], doGenerateSubmoduleConfigurations: false, extensions : [[$class: 'CloneOption', depth: 0, noTags: false, shallow: false]], - userRemoteConfigs : [remoteConfig] + userRemoteConfigs : [[url: params.GIT_URL.trim()]] ]) } } } - stage('Environment Prep') { + stage('.NET SDK Info') { steps { - sh '''#!/bin/bash -eo pipefail - echo '=== Install runtime dependencies ===' + // Sanity check the SDK and install required global tools (Java for Sonar + dotnet-sonarscanner). + sh '''#!/bin/bash -e apt-get update apt-get install -y --no-install-recommends openjdk-17-jre rm -rf /var/lib/apt/lists/* - echo '=== Verify dotnet CLI ===' + echo '### dotnet --info' 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 + echo '### Install/Update dotnet-sonarscanner' + dotnet tool install --global dotnet-sonarscanner || dotnet tool update --global dotnet-sonarscanner ''' + script { + env.PATH = "${env.PATH}:/root/.dotnet/tools" + } } } stage('Restore') { steps { - sh '''#!/bin/bash -eo pipefail - echo '=== dotnet restore ===' + // Restore solution dependencies. + sh '''#!/bin/bash -e dotnet restore AS400API.sln ''' } } - stage('SCA - Dependencies') { + stage('Build') { steps { + // Build the solution in Release mode without failing on warnings. + sh '''#!/bin/bash -e + dotnet build AS400API.sln --configuration "${BUILD_CONFIGURATION}" --no-restore + ''' + } + } + + stage('Test & Coverage') { + steps { + // Execute tests once for fast feedback and capture TRX + OpenCover reports. + sh '''#!/bin/bash -e + rm -rf "${TEST_RESULTS_DIR}" + mkdir -p "${COVERAGE_DIR}" + dotnet test AS400API.sln --configuration "${BUILD_CONFIGURATION}" --no-build \ + /p:CollectCoverage=true \ + /p:CoverletOutputFormat=opencover \ + /p:CoverletOutput="${COVERAGE_DIR}/" \ + --logger "trx;LogFileName=test-results.trx" \ + --logger "junit;LogFileName=test-results.xml" + ''' + junit testResults: "${TEST_RESULTS_DIR}/**/*.xml", allowEmptyResults: false + publishCoverage adapters: [opencoverAdapter("${COVERAGE_FILE}")], sourceFileResolver: sourceFiles('STORE_LAST_BUILD'), failNoReports: true + } + } + + stage('SonarQube: Begin') { + steps { + // Kick off Sonar analysis with project metadata and coverage location. 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 + env.SONAR_PROJECT_VERSION = env.BUILD_NUMBER ?: '0.0.0' + } + withSonarQubeEnv('sonarqube') { + sh '''#!/bin/bash -e + dotnet sonarscanner begin \ + /k:"${SONAR_PROJECT_KEY}" \ + /n:"${SONAR_PROJECT_NAME}" \ + /v:"${SONAR_PROJECT_VERSION}" \ + /d:sonar.host.url="${SONAR_HOST_URL}" \ + /d:sonar.login="${SONAR_AUTH_TOKEN}" \ + /d:sonar.cs.opencover.reportsPaths="${COVERAGE_FILE}" ''' - - 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/**' } } + stage('Rebuild for Sonar') { 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}" + // Run a rebuild under the Sonar scanner context so analysis picks up compilation data. + sh '''#!/bin/bash -e + dotnet build AS400API.sln --configuration "${BUILD_CONFIGURATION}" --no-restore --no-incremental ''' } } - stage('Main Branch Release Prep') { - when { branch 'main' } + stage('SonarQube: End') { 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 - ''' + // Close the Sonar analysis and push data to the server. + withSonarQubeEnv('sonarqube') { + sh '''#!/bin/bash -e + dotnet sonarscanner end /d:sonar.login="${SONAR_AUTH_TOKEN}" + ''' + } } } - stage('Package & Archive Artifacts') { + stage('Quality Gate') { 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 + // Wait for the Quality Gate result and fail fast on non-green outcomes. + timeout(time: 15, unit: 'MINUTES') { + def qualityGate = waitForQualityGate() + if (qualityGate.status != 'OK') { + error "SonarQube Quality Gate failed: ${qualityGate.status}" + } } } } + + stage('Archive') { + steps { + // Publish Release-ready binaries and test artifacts for downstream consumption. + sh '''#!/bin/bash -e + rm -rf "${PUBLISH_DIR}" + dotnet publish AS400API.sln --configuration "${BUILD_CONFIGURATION}" --no-restore --output "${PUBLISH_DIR}" + ''' + archiveArtifacts artifacts: "${PUBLISH_DIR}/**", fingerprint: true + archiveArtifacts artifacts: "${TEST_RESULTS_DIR}/**/*.trx", allowEmptyArchive: true + archiveArtifacts artifacts: "${COVERAGE_FILE}", allowEmptyArchive: true + } + } } post { always { - echo '=== Post-build: archiving logs ===' - script { - try { - archiveArtifacts artifacts: 'Logs/**/*.log', allowEmptyArchive: true - } catch (org.jenkinsci.plugins.workflow.steps.MissingContextVariableException ignore) { - echo 'Workspace unavailable when attempting to archive logs; skipping.' - } - } - } - success { - echo 'Build finished successfully.' - } - unstable { - echo 'Build marked unstable due to reported issues.' - } - failure { - echo 'Build failed. Review stage logs above for details.' + cleanWs() } } }