pipeline { agent { kubernetes { defaultContainer 'node' yaml """ apiVersion: v1 kind: Pod spec: nodeSelector: 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 - name: node image: node:22-bookworm command: ['cat'] tty: true volumeMounts: - 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: {} """ } } options { disableConcurrentBuilds() } parameters { booleanParam( name: 'PUBLISH_IMAGE', defaultValue: true, description: 'Build and push typhon image to Harbor on successful quality gate.' ) } environment { SUITE_NAME = 'typhon' PUSHGATEWAY_URL = 'http://platform-quality-gateway.monitoring.svc.cluster.local:9091' IMAGE_REPO = 'registry.bstein.dev/bstein/typhon' } stages { stage('Checkout') { steps { checkout scm } } stage('Prep Docker Auth') { steps { container('docker') { sh ''' set -euo pipefail mkdir -p /root/.docker cp /docker-config/config.json /root/.docker/config.json ''' } } } stage('Quality Gate') { steps { container('node') { sh ''' set -euo pipefail npm ci npm run lint npm run test:ci npm run build mkdir -p build APP_VERSION="$(node -p \"require('./package.json').version\")" echo "APP_VERSION=${APP_VERSION}" > build/version.env node <<'NODE' const fs = require('fs'); const junitPath = 'build/junit-typhon.xml'; const coveragePath = 'coverage/coverage-summary.json'; if (!fs.existsSync(junitPath)) { console.error('missing junit report:', junitPath); process.exit(1); } if (!fs.existsSync(coveragePath)) { console.error('missing coverage summary:', coveragePath); process.exit(1); } const junit = fs.readFileSync(junitPath, 'utf8'); const tests = Number((junit.match(/tests="([0-9]+)"/) || [])[1] || 0); const failures = Number((junit.match(/failures="([0-9]+)"/) || [])[1] || 0); const errors = Number((junit.match(/errors="([0-9]+)"/) || [])[1] || 0); const skipped = Number((junit.match(/skipped="([0-9]+)"/) || [])[1] || 0); const cov = JSON.parse(fs.readFileSync(coveragePath, 'utf8')); const total = cov.total || {}; 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: 85, statements: 85, functions: 85, branches: 75 } }; fs.writeFileSync('build/quality-gate.json', JSON.stringify(report, null, 2)); NODE ''' } } } stage('Publish Test Metrics') { steps { container('node') { sh ''' set -euo pipefail 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 ? 'failed' : 'ok'; function fetchCounter(targetStatus) { try { const metrics = execSync(`curl -fsS ${gateway}/metrics`, { encoding: 'utf8' }); const re = new RegExp(`platform_quality_gate_runs_total\\{[^}]*suite=\\"${suite}\\"[^}]*status=\\"${targetStatus}\\"[^}]*\\}\\s+(\\d+(?:\\.\\d+)?)`); const match = metrics.match(re); if (!match) return 0; return Number(match[1]); } catch { return 0; } } let ok = fetchCounter('ok'); let failed = fetchCounter('failed'); if (status === 'ok') ok += 1; if (status === 'failed') failed += 1; const payload = [ '# 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 typhon_quality_gate_tests_total gauge', `typhon_quality_gate_tests_total{suite="${suite}",result="total"} ${quality.tests.tests}`, `typhon_quality_gate_tests_total{suite="${suite}",result="failures"} ${quality.tests.failures}`, `typhon_quality_gate_tests_total{suite="${suite}",result="errors"} ${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}`, '' ].join('\n'); execSync(`curl -fsS --data-binary @- ${gateway}/metrics/job/platform-quality-ci/suite/${suite}`, { input: payload, stdio: ['pipe', 'inherit', 'inherit'] }); NODE ''' } } } stage('Buildx Setup') { when { expression { return params.PUBLISH_IMAGE } } steps { container('docker') { sh ''' set -euo pipefail 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 ''' } } } stage('Build & Push Image') { when { expression { return params.PUBLISH_IMAGE } } steps { container('docker') { withCredentials([ usernamePassword( credentialsId: 'harbor-robot', usernameVariable: 'HARBOR_USERNAME', passwordVariable: 'HARBOR_PASSWORD' ) ]) { sh ''' set -euo pipefail . build/version.env SHORT_SHA="$(git rev-parse --short HEAD)" IMAGE_TAG="${APP_VERSION}-${BUILD_NUMBER}-${SHORT_SHA}" 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 . { echo "IMAGE_REPO=${IMAGE_REPO}" echo "IMAGE_TAG=${IMAGE_TAG}" echo "IMAGE_CHANNEL_TAG=main" } > build/image.env ''' } } } } } post { always { script { if (fileExists('build/junit-typhon.xml')) { junit allowEmptyResults: true, testResults: 'build/junit-typhon.xml' } } archiveArtifacts artifacts: 'build/**,dist/**,coverage/**', allowEmptyArchive: true, fingerprint: true } } }