diff --git a/Jenkinsfile b/Jenkinsfile new file mode 100644 index 0000000..ff92271 --- /dev/null +++ b/Jenkinsfile @@ -0,0 +1,219 @@ +// 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() + } +} diff --git a/README.md b/README.md index a854df0..80ddfab 100644 --- a/README.md +++ b/README.md @@ -10,3 +10,21 @@ docker compose --env-file .env.prod -f docker-compose.prod.yml up --build -d # Stop docker compose -f docker-compose.prod.yml down +# Local environment setup +`.env.development`, `.env.test`, and `.env.prod` are gitignored (they hold real credentials) — +copy `.env.example` to the one you need and fill in real values before running any command above. + +# CI/CD +Every push/PR triggers the Jenkins pipeline defined in `Jenkinsfile`. Stage order: +checkout → install → environment validation → typecheck → lint → format check → unit test → +integration test → E2E test → build → Docker build → publish → deploy. Publish/deploy only run +on branches with a configured deploy target (`main` → prod, `develop`/`test` → test); other +branches validate and build only. Pipeline run status and per-stage logs are visible in the +Jenkins UI for the relevant job — see `specs/001-ci-pipeline/quickstart.md` for how to validate +the pipeline itself, and `specs/001-ci-pipeline/contracts/pipeline-stage-contract.md` for the +guarantees each stage makes. + +Required Jenkins credentials (see the header comment in `Jenkinsfile` for exact IDs): per target +environment (`test`, `prod`) a Postgres password, Redis password, JWT secret, and AWS access +key/secret, plus one shared Docker registry username/password. None of these are ever read from +a file in this repository. diff --git a/docker-compose.test.yml b/docker-compose.test.yml index e30e1e4..e8a7585 100644 --- a/docker-compose.test.yml +++ b/docker-compose.test.yml @@ -6,8 +6,6 @@ services: args: BUILD_COMMAND: ${BUILD_COMMAND} - container_name: support-test - env_file: - .env.test @@ -33,8 +31,6 @@ services: postgres: image: postgres:18-alpine - container_name: postgres-test - environment: POSTGRES_DB: ${POSTGRES_DB} POSTGRES_USER: ${POSTGRES_USER} @@ -58,8 +54,6 @@ services: redis: image: redis:7-alpine - container_name: redis-test - command: - redis-server - --requirepass diff --git a/specs/001-ci-pipeline/checklists/requirements.md b/specs/001-ci-pipeline/checklists/requirements.md index b2ae678..3da1293 100644 --- a/specs/001-ci-pipeline/checklists/requirements.md +++ b/specs/001-ci-pipeline/checklists/requirements.md @@ -36,3 +36,22 @@ Technology & Platform Constraints section already commits to Jenkins per docs/09; that mapping belongs in `/speckit-plan`, not here. - All items pass; no revision iterations were needed. + +## Implementation notes (added during /speckit-implement) + +- `docker-compose.test.yml` had fixed `container_name` values (`support-test`, + `postgres-test`, `redis-test`) on all three services — this would have made FR-009/SC-005 + (concurrent-run isolation) impossible, since Docker container names must be unique per host + regardless of Compose project. Removed them so Compose auto-names containers per project + (verified locally: two `up` runs under different `-p` project names now produce + `-postgres-1` / `-redis-1` etc. with no collision). +- `docker compose ... down` needs the same `--env-file` flag as `up`, or it can fail to resolve + service config and leave containers running — confirmed by hitting this locally; the + `Jenkinsfile`'s `post { always { ... } }` teardown includes it. +- The existing `test:unit` npm script (`vitest run` with no path filter) currently runs the + entire `tests/**/*.test.ts` glob — including integration/E2E — because `vitest.config.ts`'s + `include` isn't scoped per script; only `test:integration`/`test:e2e` narrow by passing an + explicit directory. Today's "integration" tests are instantiation-only checks (no real DB/Redis + calls yet), so this isn't currently harmful, but it means the `Unit test` stage doesn't + actually isolate unit-only coverage. Out of scope to fix here (not part of this feature's + requirements) — worth a follow-up once real integration tests exist. diff --git a/specs/001-ci-pipeline/tasks.md b/specs/001-ci-pipeline/tasks.md index 5442564..90b4416 100644 --- a/specs/001-ci-pipeline/tasks.md +++ b/specs/001-ci-pipeline/tasks.md @@ -36,10 +36,10 @@ Single project — this feature adds one new root-level file, `Jenkinsfile`, plu **Purpose**: Get a buildable pipeline skeleton and confirm the container build this pipeline will drive actually works today, before wiring stage logic into it. -- [ ] T001 Create `Jenkinsfile` at repo root with declarative pipeline skeleton: `agent`, +- [X] T001 Create `Jenkinsfile` at repo root with declarative pipeline skeleton: `agent`, `options { disableConcurrentMultipleBuilds... }`, empty `stages {}` block, and a `post` block placeholder — in `Jenkinsfile` -- [ ] T002 [P] Verify the existing multi-stage `Dockerfile` builds cleanly outside CI +- [X] T002 [P] Verify the existing multi-stage `Dockerfile` builds cleanly outside CI (`docker build --build-arg BUILD_COMMAND="npm run build:prod" -t supporthub-api-ci .`) so the pipeline's `Docker build` stage has a known-good target — no file changes, verification only @@ -56,13 +56,13 @@ run next. **⚠️ CRITICAL**: No user-story stage work can be added until this phase is complete. -- [ ] T003 Add `Checkout` stage (SCM checkout) to `Jenkinsfile` -- [ ] T004 Add `Install` stage (`npm ci`) to `Jenkinsfile` (depends on T003) -- [ ] T005 Add `Environment validation` stage to `Jenkinsfile`: materialize `.env.` from +- [X] T003 Add `Checkout` stage (SCM checkout) to `Jenkinsfile` +- [X] T004 Add `Install` stage (`npm ci`) to `Jenkinsfile` (depends on T003) +- [X] T005 Add `Environment validation` stage to `Jenkinsfile`: materialize `.env.` from Jenkins credentials (per research.md's secrets decision — never read the repo's `.env.*`), then invoke the existing Zod schema in `src/config/env.ts` so a missing/malformed variable fails immediately with its existing descriptive error (FR-002) (depends on T004) -- [ ] T006 [P] Add a `Generate Prisma client` step (`npm run prisma:generate`) to `Jenkinsfile`, +- [X] T006 [P] Add a `Generate Prisma client` step (`npm run prisma:generate`) to `Jenkinsfile`, required before `Typecheck`/`Build` can succeed (depends on T004) **Checkpoint**: Checkout → install → env validation → Prisma generate all run and pass on a clean @@ -82,23 +82,23 @@ missing required env var and confirm `Environment validation` fails first (Quick ### Implementation for User Story 1 -- [ ] T007 [US1] Add `Typecheck` stage (`npm run typecheck`) to `Jenkinsfile` (depends on T006) -- [ ] T008 [US1] Add `Lint` stage to `Jenkinsfile`, running both `npm run lint` and +- [X] T007 [US1] Add `Typecheck` stage (`npm run typecheck`) to `Jenkinsfile` (depends on T006) +- [X] T008 [US1] Add `Lint` stage to `Jenkinsfile`, running both `npm run lint` and `npx tsx scripts/check-architecture.ts` (module-boundary check, matches `.husky/pre-commit` and enforces Constitution Principle III server-side) (depends on T007) -- [ ] T009 [US1] Add `Format check` stage (`npm run format:check`) to `Jenkinsfile` (depends on T008) -- [ ] T010 [US1] Add `Unit test` stage (`npm run test:unit`) to `Jenkinsfile` (depends on T009) -- [ ] T011 [US1] Add a step before `Integration test` that brings up ephemeral `postgres`/`redis` +- [X] T009 [US1] Add `Format check` stage (`npm run format:check`) to `Jenkinsfile` (depends on T008) +- [X] T010 [US1] Add `Unit test` stage (`npm run test:unit`) to `Jenkinsfile` (depends on T009) +- [X] T011 [US1] Add a step before `Integration test` that brings up ephemeral `postgres`/`redis` via `docker-compose.test.yml`, with the Compose project name parameterized by `${BUILD_NUMBER}` for run isolation (FR-009), in `Jenkinsfile` (depends on T010) -- [ ] T012 [US1] Add `Integration test` stage (`npm run test:integration`) to `Jenkinsfile` +- [X] T012 [US1] Add `Integration test` stage (`npm run test:integration`) to `Jenkinsfile` (depends on T011) -- [ ] T013 [US1] Add `E2E test` stage (`npm run test:e2e`) to `Jenkinsfile` (depends on T011) -- [ ] T014 [US1] Add `Build` stage (`npm run build:prod`, or the target-specific `build:*` script +- [X] T013 [US1] Add `E2E test` stage (`npm run test:e2e`) to `Jenkinsfile` (depends on T011) +- [X] T014 [US1] Add `Build` stage (`npm run build:prod`, or the target-specific `build:*` script matching the resolved Deploy Target) to `Jenkinsfile` (depends on T012, T013) -- [ ] T015 [US1] Add `Docker build` stage using the root `Dockerfile` (T002's verified command) to +- [X] T015 [US1] Add `Docker build` stage using the root `Dockerfile` (T002's verified command) to `Jenkinsfile` (depends on T014) -- [ ] T016 [US1] Add a `post` block to `Jenkinsfile` that surfaces which stage failed and its +- [X] T016 [US1] Add a `post` block to `Jenkinsfile` that surfaces which stage failed and its captured output on failure (FR-004, SC-002), and tears down the ephemeral `docker-compose.test.yml` stack (`always`) regardless of outcome - [ ] T017 [US1] Manually run Quickstart Scenarios 1, 2, and 5 from @@ -123,18 +123,18 @@ commands (Quickstart Scenario 3); push to a branch with no Deploy Target and con ### Implementation for User Story 2 -- [ ] T018 [US2] Add branch → Deploy Target resolution logic to `Jenkinsfile` (e.g. `main` → prod, +- [X] T018 [US2] Add branch → Deploy Target resolution logic to `Jenkinsfile` (e.g. `main` → prod, a designated test branch → test; everything else → no Deploy Target) (depends on T015) -- [ ] T019 [US2] Add `Publish` stage to `Jenkinsfile`: push the `Docker build` image to a +- [X] T019 [US2] Add `Publish` stage to `Jenkinsfile`: push the `Docker build` image to a container registry, guarded to run only when a Deploy Target was resolved (T018) (depends on T018) -- [ ] T020 [US2] Add a step to `Jenkinsfile` that generates the target's `.env.` from +- [X] T020 [US2] Add a step to `Jenkinsfile` that generates the target's `.env.` from Jenkins credentials immediately before deploy (never from the repo copy, per research.md), scoped to the `Deploy` stage's workspace only (depends on T018) -- [ ] T021 [US2] Add `Deploy` stage to `Jenkinsfile`: run +- [X] T021 [US2] Add `Deploy` stage to `Jenkinsfile`: run `docker compose --env-file -f docker-compose..yml up -d` against the resolved Deploy Target, guarded the same way as `Publish` (depends on T019, T020) -- [ ] T022 [US2] Confirm (via `Jenkinsfile` `when` conditions) that `Publish`/`Deploy` are marked +- [X] T022 [US2] Confirm (via `Jenkinsfile` `when` conditions) that `Publish`/`Deploy` are marked `skipped`, not `failed`, on runs with no resolved Deploy Target (depends on T018) - [ ] T023 [US2] Manually run Quickstart Scenarios 3 and 4 from `specs/001-ci-pipeline/quickstart.md` against a real Jenkins job and confirm both pass, @@ -150,13 +150,13 @@ cleanly after `Docker build`. **Purpose**: Documentation and final verification once both stories are implemented. -- [ ] T024 [P] Add a short "CI/CD" section to `README.md` describing how the pipeline is +- [X] T024 [P] Add a short "CI/CD" section to `README.md` describing how the pipeline is triggered, where to view run status, and how to configure required Jenkins credentials (cross-reference `specs/001-ci-pipeline/quickstart.md`) -- [ ] T025 [P] Review `Jenkinsfile` line-by-line to confirm no credential value or literal +- [X] T025 [P] Review `Jenkinsfile` line-by-line to confirm no credential value or literal environment secret was hardcoded anywhere in the file (SC-004) — should only ever reference Jenkins credential IDs, never raw values -- [ ] T026 Update `specs/001-ci-pipeline/checklists/requirements.md` Notes if implementation +- [X] T026 Update `specs/001-ci-pipeline/checklists/requirements.md` Notes if implementation surfaced any spec gap not previously captured ---