// CI/CD pipeline for supporthub-api. // // Stage order and guarantees are defined by: // - .specify/memory/constitution.md -> "Testing, Observability & CI/CD Gates" // - specs/001-ci-pipeline/contracts/pipeline-stage-contract.md // // Prerequisites (configured on the Jenkins side, never in this repo): // - Agents with Docker and Node.js 20+ available (label: 'docker && node20') // - Credentials (Secret text unless noted) per target environment in [test, prod]: // -postgres-password, -redis-password, -jwt-secret // -aws-access-key-id, -aws-secret-access-key // Plus one Username/Password credential: docker-registry-credentials // - A DOCKER_REGISTRY value (e.g. via a "CI_DOCKER_REGISTRY" global Jenkins env var, // or override the default below) pointing at the org's actual image registry. // // This file intentionally contains no secret values, only credential IDs — see // specs/001-ci-pipeline/research.md "Environment/secrets handling in the pipeline". pipeline { agent { label 'docker && node20' } options { timestamps() buildDiscarder(logRotator(numToKeepStr: '30')) // Each build gets its own workspace by default; Compose project names below are // additionally scoped by BUILD_NUMBER so concurrent runs never share containers, // networks, or volumes (FR-009 / SC-005). ansiColor('xterm') } environment { DOCKER_REGISTRY = "${env.CI_DOCKER_REGISTRY ?: 'registry.example.com/supporthub'}" IMAGE_NAME = 'supporthub-api' COMPOSE_PROJECT = "supporthub-ci-${env.BUILD_NUMBER}" } stages { stage('Checkout') { steps { checkout scm } } stage('Install') { steps { sh 'npm ci' } } stage('Environment validation') { steps { script { resolveDeployTarget() writeTargetEnvFile(env.TARGET_ENV == 'none' ? 'test' : env.TARGET_ENV) } // Reuses the existing Zod schema in src/config/env.ts as-is: it throws a // specific, descriptive error on safeParse failure, so this fails fast with // no new validation logic (FR-002). sh "npx tsx --env-file=.env.${env.TARGET_ENV == 'none' ? 'test' : env.TARGET_ENV} -e \"import('./src/config/env.ts').then(() => console.log('Environment OK'))\"" } } stage('Generate Prisma client') { steps { sh 'npm run prisma:generate' } } stage('Typecheck') { steps { sh 'npm run typecheck' } } stage('Lint') { steps { sh 'npm run lint' // Enforces Constitution Principle III (module boundaries) server-side — // mirrors .husky/pre-commit so a bypassed/missing local hook can't merge // a boundary violation. sh 'npx tsx scripts/check-architecture.ts' } } stage('Format check') { steps { sh 'npm run format:check' } } stage('Unit test') { steps { sh 'npm run test:unit' } } stage('Integration test') { steps { sh "docker compose -p ${env.COMPOSE_PROJECT} --env-file .env.test -f docker-compose.test.yml up -d --wait postgres redis" sh "docker compose -p ${env.COMPOSE_PROJECT} --env-file .env.test -f docker-compose.test.yml run --rm --build app npm run test:integration" } } stage('E2E test') { steps { sh "docker compose -p ${env.COMPOSE_PROJECT} --env-file .env.test -f docker-compose.test.yml run --rm app npm run test:e2e" } } stage('Build') { steps { script { def buildTarget = env.TARGET_ENV == 'none' ? 'test' : env.TARGET_ENV sh "npm run build:${buildTarget}" } } } stage('Docker build') { steps { script { def buildTarget = env.TARGET_ENV == 'none' ? 'test' : env.TARGET_ENV env.IMAGE_TAG = "${env.DOCKER_REGISTRY}/${env.IMAGE_NAME}:${buildTarget}-${env.BUILD_NUMBER}" } sh "docker build --build-arg BUILD_COMMAND=\"npm run build:${env.TARGET_ENV == 'none' ? 'test' : env.TARGET_ENV}\" -t ${env.IMAGE_TAG} ." } } stage('Publish') { when { expression { env.TARGET_ENV != 'none' } } steps { withCredentials([usernamePassword(credentialsId: 'docker-registry-credentials', usernameVariable: 'REGISTRY_USER', passwordVariable: 'REGISTRY_PASSWORD')]) { sh ''' echo "$REGISTRY_PASSWORD" | docker login "$DOCKER_REGISTRY" -u "$REGISTRY_USER" --password-stdin docker push "$IMAGE_TAG" docker logout "$DOCKER_REGISTRY" ''' } } } stage('Deploy') { when { expression { env.TARGET_ENV != 'none' } } steps { script { writeTargetEnvFile(env.TARGET_ENV) } sh "docker compose --env-file .env.${env.TARGET_ENV} -f docker-compose.${env.TARGET_ENV == 'prod' ? 'prod' : 'test'}.yml up -d" } } } post { always { // Tear down the ephemeral integration/E2E stack regardless of outcome, and never // leave a generated .env.* file behind in the workspace. --env-file must be passed // to `down` too, or Compose can fail to resolve service config and leave containers // running (confirmed while validating this pipeline locally). sh "docker compose -p ${env.COMPOSE_PROJECT} --env-file .env.test -f docker-compose.test.yml down -v --remove-orphans || true" sh 'rm -f .env.test .env.prod .env.development' cleanWs() } failure { echo "Pipeline failed at stage: ${currentBuild.result}. See the failing stage's log above for the exact command and output — no local reproduction should be necessary (FR-004)." } } } // Resolves env.TARGET_ENV from the branch being built: // main -> prod // develop/test -> test // anything else -> none (validate-only; Publish/Deploy stages are skipped, not failed) void resolveDeployTarget() { def branch = env.BRANCH_NAME ?: '' if (branch == 'main') { env.TARGET_ENV = 'prod' } else if (branch == 'develop' || branch == 'test') { env.TARGET_ENV = 'test' } else { env.TARGET_ENV = 'none' } echo "Resolved deploy target for branch '${branch}': ${env.TARGET_ENV}" } // Writes .env. into the workspace from Jenkins credentials — never read from a // file committed to the repository (constitution governance: "secrets never committed"). void writeTargetEnvFile(String target) { withCredentials([ string(credentialsId: "${target}-postgres-password", variable: 'POSTGRES_PASSWORD'), string(credentialsId: "${target}-redis-password", variable: 'REDIS_PASSWORD'), string(credentialsId: "${target}-jwt-secret", variable: 'JWT_SECRET'), string(credentialsId: "${target}-aws-access-key-id", variable: 'AWS_ACCESS_KEY_ID'), string(credentialsId: "${target}-aws-secret-access-key", variable: 'AWS_SECRET_ACCESS_KEY'), ]) { def port = target == 'prod' ? '4503' : (target == 'test' ? '4502' : '4501') def dbName = target == 'prod' ? 'myapp_prod' : (target == 'test' ? 'myapp_test' : 'support_dev') def dbUser = target == 'prod' ? 'myapp_prod' : (target == 'test' ? 'myapp_test' : 'support_user') writeFile file: ".env.${target}", text: """ NODE_ENV=${target == 'prod' ? 'production' : target} PORT=${port} BUILD_COMMAND=npm run build:${target} POSTGRES_HOST=postgres POSTGRES_DB=${dbName} POSTGRES_USER=${dbUser} POSTGRES_PASSWORD=${POSTGRES_PASSWORD} DATABASE_URL=postgresql://${dbUser}:${POSTGRES_PASSWORD}@postgres:5432/${dbName} REDIS_HOST=redis REDIS_PORT=6379 REDIS_PASSWORD=${REDIS_PASSWORD} JWT_SECRET=${JWT_SECRET} AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID} AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY} CORS_ORIGINS=${target == 'prod' ? 'https://app.supporthub.com,https://admin.supporthub.com' : 'http://localhost:3000'} """.stripIndent().trim() } }