feat: implement CI pipeline (Jenkinsfile) for 001-ci-pipeline

Implements tasks T001-T016, T018-T022, T024-T026 from
specs/001-ci-pipeline/tasks.md (T017/T023 need a real Jenkins
instance to verify and are left for manual follow-up).

- Add Jenkinsfile: checkout -> install -> environment validation
  -> typecheck -> lint (+ architecture check) -> format check ->
  unit -> integration -> E2E -> build -> Docker build -> publish
  -> deploy, matching the constitution's required stage order.
  Secrets are always injected from Jenkins credentials at runtime,
  never read from a repo-committed file. Publish/Deploy are skipped
  (not failed) on branches with no resolved deploy target.
- Fix docker-compose.test.yml: remove fixed container_name on
  app/postgres/redis, which would have made concurrent CI runs
  collide (FR-009). Verified locally that two runs under different
  -p project names no longer share container/volume/network names.
- Document the pipeline and local .env setup in README.md.
- Mark completed tasks in specs/001-ci-pipeline/tasks.md and record
  the container_name/compose-down-env-file findings in the spec's
  requirements checklist notes.

Locally verified passing: Dockerfile build, typecheck, lint,
architecture check, format check, unit test suite, and the edited
docker-compose.test.yml bringing up postgres/redis with isolated
per-project container names.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
saqib mir
2026-08-21 17:25:46 +05:30
co-authored by Claude Sonnet 5
parent dd2d803c93
commit 2dffe58496
5 changed files with 280 additions and 30 deletions
Vendored
+219
View File
@@ -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 <env> in [test, prod]:
// <env>-postgres-password, <env>-redis-password, <env>-jwt-secret
// <env>-aws-access-key-id, <env>-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.<target> 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()
}
}
+18
View File
@@ -10,3 +10,21 @@ docker compose --env-file .env.prod -f docker-compose.prod.yml up --build -d
# Stop # Stop
docker compose -f docker-compose.prod.yml down 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.
-6
View File
@@ -6,8 +6,6 @@ services:
args: args:
BUILD_COMMAND: ${BUILD_COMMAND} BUILD_COMMAND: ${BUILD_COMMAND}
container_name: support-test
env_file: env_file:
- .env.test - .env.test
@@ -33,8 +31,6 @@ services:
postgres: postgres:
image: postgres:18-alpine image: postgres:18-alpine
container_name: postgres-test
environment: environment:
POSTGRES_DB: ${POSTGRES_DB} POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER} POSTGRES_USER: ${POSTGRES_USER}
@@ -58,8 +54,6 @@ services:
redis: redis:
image: redis:7-alpine image: redis:7-alpine
container_name: redis-test
command: command:
- redis-server - redis-server
- --requirepass - --requirepass
@@ -36,3 +36,22 @@
Technology & Platform Constraints section already commits to Jenkins per docs/09; that mapping Technology & Platform Constraints section already commits to Jenkins per docs/09; that mapping
belongs in `/speckit-plan`, not here. belongs in `/speckit-plan`, not here.
- All items pass; no revision iterations were needed. - 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
`<project>-postgres-1` / `<project>-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.
+24 -24
View File
@@ -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 **Purpose**: Get a buildable pipeline skeleton and confirm the container build this pipeline will
drive actually works today, before wiring stage logic into it. 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` `options { disableConcurrentMultipleBuilds... }`, empty `stages {}` block, and a `post`
block placeholder — in `Jenkinsfile` 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 (`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 the pipeline's `Docker build` stage has a known-good target — no file changes, verification
only only
@@ -56,13 +56,13 @@ run next.
**⚠️ CRITICAL**: No user-story stage work can be added until this phase is complete. **⚠️ CRITICAL**: No user-story stage work can be added until this phase is complete.
- [ ] T003 Add `Checkout` stage (SCM checkout) to `Jenkinsfile` - [X] T003 Add `Checkout` stage (SCM checkout) to `Jenkinsfile`
- [ ] T004 Add `Install` stage (`npm ci`) to `Jenkinsfile` (depends on T003) - [X] T004 Add `Install` stage (`npm ci`) to `Jenkinsfile` (depends on T003)
- [ ] T005 Add `Environment validation` stage to `Jenkinsfile`: materialize `.env.<target>` from - [X] T005 Add `Environment validation` stage to `Jenkinsfile`: materialize `.env.<target>` from
Jenkins credentials (per research.md's secrets decision — never read the repo's `.env.*`), 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 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) 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) required before `Typecheck`/`Build` can succeed (depends on T004)
**Checkpoint**: Checkout → install → env validation → Prisma generate all run and pass on a clean **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 ### Implementation for User Story 1
- [ ] T007 [US1] Add `Typecheck` stage (`npm run typecheck`) to `Jenkinsfile` (depends on T006) - [X] 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] 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` `npx tsx scripts/check-architecture.ts` (module-boundary check, matches `.husky/pre-commit`
and enforces Constitution Principle III server-side) (depends on T007) 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) - [X] 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) - [X] 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] 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 via `docker-compose.test.yml`, with the Compose project name parameterized by
`${BUILD_NUMBER}` for run isolation (FR-009), in `Jenkinsfile` (depends on T010) `${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) (depends on T011)
- [ ] T013 [US1] Add `E2E test` stage (`npm run test:e2e`) to `Jenkinsfile` (depends on T011) - [X] 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] 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) 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) `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 captured output on failure (FR-004, SC-002), and tears down the ephemeral
`docker-compose.test.yml` stack (`always`) regardless of outcome `docker-compose.test.yml` stack (`always`) regardless of outcome
- [ ] T017 [US1] Manually run Quickstart Scenarios 1, 2, and 5 from - [ ] 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 ### 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) 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 container registry, guarded to run only when a Deploy Target was resolved (T018) (depends
on T018) on T018)
- [ ] T020 [US2] Add a step to `Jenkinsfile` that generates the target's `.env.<target>` from - [X] T020 [US2] Add a step to `Jenkinsfile` that generates the target's `.env.<target>` from
Jenkins credentials immediately before deploy (never from the repo copy, per research.md), Jenkins credentials immediately before deploy (never from the repo copy, per research.md),
scoped to the `Deploy` stage's workspace only (depends on T018) 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 <generated> -f docker-compose.<target>.yml up -d` against the `docker compose --env-file <generated> -f docker-compose.<target>.yml up -d` against the
resolved Deploy Target, guarded the same way as `Publish` (depends on T019, T020) 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) `skipped`, not `failed`, on runs with no resolved Deploy Target (depends on T018)
- [ ] T023 [US2] Manually run Quickstart Scenarios 3 and 4 from - [ ] 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, `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. **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 triggered, where to view run status, and how to configure required Jenkins credentials
(cross-reference `specs/001-ci-pipeline/quickstart.md`) (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 environment secret was hardcoded anywhere in the file (SC-004) — should only ever reference
Jenkins credential IDs, never raw values 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 surfaced any spec gap not previously captured
--- ---