def isTimerBuild() { return !currentBuild.getBuildCauses('hudson.triggers.TimerTrigger$TimerTriggerCause').isEmpty() } def isScmBuild() { return !currentBuild.getBuildCauses('hudson.triggers.SCMTrigger$SCMTriggerCause').isEmpty() } def shouldPublishImage() { return params.PUBLISH_IMAGE } pipeline { agent none options { disableConcurrentBuilds() } parameters { booleanParam( name: 'PUBLISH_IMAGE', defaultValue: false, description: 'Build and push the Typhon image to Harbor. Regular SCM and timer runs only publish test telemetry.' ) } environment { SUITE_NAME = 'typhon' PUSHGATEWAY_URL = 'http://platform-quality-gateway.monitoring.svc.cluster.local:9091' IMAGE_REPO = 'registry.bstein.dev/bstein/typhon' } stages { stage('Quality and metrics') { agent { kubernetes { defaultContainer 'node' yaml """ apiVersion: v1 kind: Pod spec: nodeSelector: hardware: rpi5 kubernetes.io/arch: arm64 node-role.kubernetes.io/worker: "true" containers: - name: node image: node:22-bookworm-slim command: ['cat'] tty: true volumeMounts: - name: workspace-volume mountPath: /home/jenkins/agent volumes: - name: workspace-volume emptyDir: {} """ } } stages { stage('Checkout') { steps { checkout scm } } stage('Run quality gate') { steps { container('node') { sh ''' set -eu apt-get update DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends curl ca-certificates rm -rf /var/lib/apt/lists/* set +e bash <<'GATE' set -u npm ci lint_rc=0 test_rc=0 build_rc=0 npm run lint || lint_rc=$? npm run test:ci || test_rc=$? npm run build || build_rc=$? mkdir -p build APP_VERSION="$(node -p "require('./package.json').version")" echo "APP_VERSION=${APP_VERSION}" > build/version.env export lint_rc test_rc build_rc node <<'NODE' const fs = require('fs'); const path = require('path'); const junitPath = 'build/junit-typhon.xml'; const coveragePath = 'coverage/coverage-summary.json'; const sourceRoots = ['src', 'tests', 'scripts']; const sourceExts = new Set(['.ts', '.js', '.cjs', '.mjs', '.sh']); function unescapeXml(value) { return String(value || '') .split('"').join('"') .split(''').join("'") .split('<').join('<') .split('>').join('>') .split('&').join('&'); } function attr(attrs, name) { const needle = `${name}="`; let start = attrs.indexOf(needle); while (start > 0) { const previous = attrs.charCodeAt(start - 1); if (previous === 32 || previous === 9 || previous === 10 || previous === 13) { break; } start = attrs.indexOf(needle, start + 1); } if (start < 0) return ''; const valueStart = start + needle.length; const valueEnd = attrs.indexOf('"', valueStart); return valueEnd < 0 ? '' : unescapeXml(attrs.slice(valueStart, valueEnd)); } function collectSourceFiles(rootDir) { const files = []; const stack = [rootDir]; while (stack.length > 0) { const current = stack.pop(); if (!fs.existsSync(current)) { continue; } for (const entry of fs.readdirSync(current, { withFileTypes: true })) { const fullPath = path.join(current, entry.name); if (entry.isDirectory()) { stack.push(fullPath); continue; } if (sourceExts.has(path.extname(entry.name))) { files.push(fullPath); } } } return files.sort(); } function categoryForClassname(classname) { const normalized = String(classname || '').split(String.fromCharCode(92)).join('/'); const relative = normalized.includes('/tests/') ? normalized.slice(normalized.indexOf('/tests/') + '/tests/'.length) : (normalized.startsWith('tests/') ? normalized.slice('tests/'.length) : normalized); const parts = relative.split('/').filter(Boolean); if (parts.length <= 1 || parts[0].endsWith('.test.ts')) { return 'unit'; } return parts[0]; } function parseTestCases(junit) { const cases = []; const re = new RegExp(']*)>(.*?)|]*)/>', 'gs'); let match; while ((match = re.exec(junit)) !== null) { const attrs = match[1] || match[3] || ''; const body = match[2] || ''; const classname = attr(attrs, 'classname') || 'typhon'; const title = attr(attrs, 'name') || classname; const status = body.includes(' collectSourceFiles(root)); const overLimitFiles = sourceFiles .map((file) => ({ file, lines: fs.readFileSync(file, 'utf8').split(String.fromCharCode(10)).length })) .filter((item) => item.lines > 500); const report = { suite: 'typhon', tests: { tests, failures, errors, skipped }, coverage: { lines: total.lines?.pct ?? 0, statements: total.statements?.pct ?? 0, functions: total.functions?.pct ?? 0, branches: total.branches?.pct ?? 0 }, thresholds: { lines: 95, statements: 95, functions: 95, branches: 75 }, hygiene: { sourceFilesTotal: sourceFiles.length, sourceLinesOver500: overLimitFiles.length }, checks: { tests: failures > 0 || errors > 0 || Number(process.env.test_rc || 0) !== 0 ? 'failed' : 'ok', coverage: 'ok', loc: overLimitFiles.length === 0 ? 'ok' : 'failed', style: Number(process.env.lint_rc || 0) === 0 ? 'ok' : 'failed', gate_glue: 'ok', sonarqube: 'not_applicable', supply_chain: 'not_applicable' }, testCases: parseTestCases(junit) }; report.checks.coverage = ( report.coverage.lines >= report.thresholds.lines && report.coverage.statements >= report.thresholds.statements && report.coverage.functions >= report.thresholds.functions && report.coverage.branches >= report.thresholds.branches ) ? 'ok' : 'failed'; if (report.testCases.length === 0) { report.testCases.push({ category: 'unit', test: '__no_test_cases__', status: junit ? 'skipped' : 'failed' }); } fs.writeFileSync('build/quality-gate.json', JSON.stringify(report, null, 2)); if ( Number(process.env.lint_rc || 0) !== 0 || Number(process.env.test_rc || 0) !== 0 || Number(process.env.build_rc || 0) !== 0 || Object.values(report.checks).includes('failed') ) { process.exit(1); } NODE GATE gate_rc=$? set -e printf '%s\n' "${gate_rc}" > build/quality-gate.rc if [ ! -f build/quality-gate.json ]; then mkdir -p build cat > build/quality-gate.json <<'JSON' { "suite": "typhon", "tests": { "tests": 0, "failures": 1, "errors": 0, "skipped": 0 }, "coverage": { "lines": 0, "statements": 0, "functions": 0, "branches": 0 }, "thresholds": { "lines": 95, "statements": 95, "functions": 95, "branches": 75 }, "hygiene": { "sourceFilesTotal": 0, "sourceLinesOver500": 0 }, "checks": { "tests": "failed", "coverage": "failed", "loc": "ok", "style": "failed", "gate_glue": "failed", "sonarqube": "not_applicable", "supply_chain": "not_applicable" }, "testCases": [ { "category": "unit", "test": "__no_test_cases__", "status": "failed" } ] } JSON fi ''' } } } stage('Publish test metrics') { steps { container('node') { sh ''' set -eu node <<'NODE' const fs = require('fs'); const { execSync } = require('child_process'); const gateway = process.env.PUSHGATEWAY_URL; const suite = process.env.SUITE_NAME || 'typhon'; const qualityPath = 'build/quality-gate.json'; if (!fs.existsSync(qualityPath)) { throw new Error('quality report missing'); } const quality = JSON.parse(fs.readFileSync(qualityPath, 'utf8')); const status = quality.tests.failures > 0 || quality.tests.errors > 0 || Object.values(quality.checks || {}).includes('failed') ? 'failed' : 'ok'; const branch = process.env.BRANCH_NAME || process.env.GIT_BRANCH || 'unknown'; const buildNumber = process.env.BUILD_NUMBER || 'unknown'; const jenkinsJob = process.env.JOB_NAME || suite; const sourceFilesTotal = Number(quality.hygiene?.sourceFilesTotal ?? 0); const sourceLinesOver500 = Number(quality.hygiene?.sourceLinesOver500 ?? 0); function esc(value) { return String(value ?? '') .split(String.fromCharCode(92)).join(String.fromCharCode(92, 92)) .split('"').join(String.fromCharCode(92) + '"') .split(String.fromCharCode(10)).join(String.fromCharCode(92) + 'n'); } function labelString(labels) { return `{${Object.entries(labels).map(([key, value]) => `${key}="${esc(value)}"`).join(',')}}`; } function fetchCounter(targetStatus) { try { const metrics = execSync(`curl -fsS ${gateway}/metrics`, { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 }); for (const line of metrics.split(String.fromCharCode(10))) { if ( line.startsWith('platform_quality_gate_runs_total{') && line.includes(`suite="${suite}"`) && line.includes(`status="${targetStatus}"`) ) { return Number(line.trim().split(' ').filter(Boolean).pop() || 0); } } return 0; } catch { return 0; } } let ok = fetchCounter('ok'); let failed = fetchCounter('failed'); if (status === 'ok') ok += 1; if (status === 'failed') failed += 1; const passed = Math.max(quality.tests.tests - quality.tests.failures - quality.tests.errors - quality.tests.skipped, 0); const buildLabels = labelString({ suite, branch, build_number: buildNumber, jenkins_job: jenkinsJob }); const checks = { tests: 'failed', coverage: 'failed', loc: 'failed', style: 'failed', gate_glue: 'failed', sonarqube: 'not_applicable', supply_chain: 'not_applicable', ...(quality.checks || {}) }; const lines = [ '# TYPE platform_quality_gate_runs_total counter', `platform_quality_gate_runs_total{suite="${suite}",status="ok"} ${ok}`, `platform_quality_gate_runs_total{suite="${suite}",status="failed"} ${failed}`, '# TYPE platform_quality_gate_build_info gauge', `platform_quality_gate_build_info${buildLabels} 1`, '# TYPE platform_quality_gate_tests_total gauge', `platform_quality_gate_tests_total{suite="${suite}",result="passed"} ${passed}`, `platform_quality_gate_tests_total{suite="${suite}",result="failed"} ${quality.tests.failures}`, `platform_quality_gate_tests_total{suite="${suite}",result="error"} ${quality.tests.errors}`, `platform_quality_gate_tests_total{suite="${suite}",result="skipped"} ${quality.tests.skipped}`, '# TYPE typhon_quality_gate_tests_total gauge', `typhon_quality_gate_tests_total{suite="${suite}",result="passed"} ${passed}`, `typhon_quality_gate_tests_total{suite="${suite}",result="failed"} ${quality.tests.failures}`, `typhon_quality_gate_tests_total{suite="${suite}",result="error"} ${quality.tests.errors}`, `typhon_quality_gate_tests_total{suite="${suite}",result="skipped"} ${quality.tests.skipped}`, '# TYPE typhon_quality_gate_coverage_percent gauge', `typhon_quality_gate_coverage_percent{suite="${suite}",scope="lines"} ${quality.coverage.lines}`, `typhon_quality_gate_coverage_percent{suite="${suite}",scope="statements"} ${quality.coverage.statements}`, `typhon_quality_gate_coverage_percent{suite="${suite}",scope="functions"} ${quality.coverage.functions}`, `typhon_quality_gate_coverage_percent{suite="${suite}",scope="branches"} ${quality.coverage.branches}`, '# TYPE platform_quality_gate_workspace_line_coverage_percent gauge', `platform_quality_gate_workspace_line_coverage_percent{suite="${suite}"} ${quality.coverage.lines}`, '# TYPE platform_quality_gate_source_files_total gauge', `platform_quality_gate_source_files_total{suite="${suite}"} ${sourceFilesTotal}`, '# TYPE platform_quality_gate_source_lines_over_500_total gauge', `platform_quality_gate_source_lines_over_500_total{suite="${suite}"} ${sourceLinesOver500}`, '# TYPE typhon_quality_gate_checks_total gauge', ...Object.entries(checks).map(([check, result]) => `typhon_quality_gate_checks_total${labelString({ suite, check, result })} 1`), '# TYPE platform_quality_gate_test_case_result gauge', ...(quality.testCases || []).map((item) => `platform_quality_gate_test_case_result${labelString({ suite, branch, build_number: buildNumber, jenkins_job: jenkinsJob, category: item.category || 'unit', test: item.test || '__no_test_cases__', status: item.status || 'skipped' })} 1`), '' ]; try { execSync(`curl -fsS --data-binary @- ${gateway}/metrics/job/platform-quality-ci/suite/${suite}`, { input: lines.join(String.fromCharCode(10)), stdio: ['pipe', 'inherit', 'inherit'] }); } catch (error) { console.error(`metrics publish failed for suite=${suite}:`, error.message); } NODE ''' } } } stage('Enforce quality gate') { steps { container('node') { sh ''' set -eu test "$(cat build/quality-gate.rc 2>/dev/null || echo 1)" -eq 0 ''' } } } } post { always { archiveArtifacts artifacts: 'build/**,dist/**,coverage/**', allowEmptyArchive: true, fingerprint: true } } } stage('Build & push image') { when { beforeAgent true expression { return shouldPublishImage() } } agent { kubernetes { defaultContainer 'docker' yaml """ apiVersion: v1 kind: Pod spec: nodeSelector: hardware: rpi5 kubernetes.io/arch: arm64 node-role.kubernetes.io/worker: "true" containers: - name: dind image: docker:27-dind securityContext: privileged: true env: - name: DOCKER_TLS_CERTDIR value: "" command: - /bin/sh - -c args: - | set -eu exec dockerd-entrypoint.sh --mtu=1400 --host=unix:///var/run/docker.sock --host=tcp://0.0.0.0:2375 volumeMounts: - name: dind-storage mountPath: /var/lib/docker - name: workspace-volume mountPath: /home/jenkins/agent - name: docker image: docker:27 command: ['cat'] tty: true env: - name: DOCKER_HOST value: tcp://localhost:2375 - name: DOCKER_TLS_CERTDIR value: "" volumeMounts: - name: docker-config-writable mountPath: /root/.docker - name: docker-config-secret mountPath: /docker-config - name: workspace-volume mountPath: /home/jenkins/agent volumes: - name: docker-config-secret secret: secretName: harbor-robot-pipeline items: - key: .dockerconfigjson path: config.json - name: docker-config-writable emptyDir: {} - name: dind-storage emptyDir: {} - name: workspace-volume emptyDir: {} """ } } steps { checkout scm container('docker') { sh ''' set -eu mkdir -p build mkdir -p /root/.docker cp /docker-config/config.json /root/.docker/config.json seq 1 20 | while read _; do docker info >/dev/null 2>&1 && break || sleep 2 done BUILDER_NAME="typhon-${BUILD_NUMBER}" docker buildx rm "${BUILDER_NAME}" >/dev/null 2>&1 || true docker buildx create --name "${BUILDER_NAME}" --driver docker-container --bootstrap --use APP_VERSION="$(sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\\([^"]*\\)".*/\\1/p' package.json | head -n 1)" SHORT_SHA="$(printf '%s' "${GIT_COMMIT:-nohash}" | cut -c1-8)" IMAGE_TAG="${APP_VERSION}-${BUILD_NUMBER}-${SHORT_SHA}" { echo "IMAGE_REPO=${IMAGE_REPO}" echo "IMAGE_TAG=${IMAGE_TAG}" echo "IMAGE_CHANNEL_TAG=main" } > build/image.env ''' withCredentials([ usernamePassword( credentialsId: 'harbor-robot', usernameVariable: 'HARBOR_USERNAME', passwordVariable: 'HARBOR_PASSWORD' ) ]) { sh ''' set -eu . build/image.env printf '%s' "${HARBOR_PASSWORD}" | docker login registry.bstein.dev -u "${HARBOR_USERNAME}" --password-stdin docker buildx build --platform linux/arm64 \ --provenance=false \ --tag "${IMAGE_REPO}:${IMAGE_TAG}" \ --tag "${IMAGE_REPO}:main" \ --push . ''' } } } } } }