// Jenkins pipeline for YesChef. // // Stages: // 1. Restore + build the .NET backend solution // 2. Run backend unit tests // 3. Run backend integration tests against a sidecar Postgres container named `postgres` // (Testcontainers also works because the docker socket is mounted) // 4. Install, type-check, unit-test, and build the SvelteKit frontend // 5. Build the backend and frontend Docker images from their existing Dockerfiles // // Requires the Jenkins Docker Pipeline plugin, an agent with Docker available, and a // workspace user that can talk to the Docker daemon (the host docker socket is mounted // into the build containers so Testcontainers can spawn its own DB instances). pipeline { agent any options { timestamps() timeout(time: 45, unit: 'MINUTES') buildDiscarder(logRotator(numToKeepStr: '20')) disableConcurrentBuilds() } environment { DOTNET_SDK_IMAGE = 'mcr.microsoft.com/dotnet/sdk:10.0' NODE_IMAGE = 'node:22-slim' POSTGRES_IMAGE = 'postgres:17' POSTGRES_DB = 'yeschef' POSTGRES_USER = 'yeschef' POSTGRES_PASSWORD = 'yeschef' // Gitea container registry target. The registry lives on the same host as // Gitea itself; the owner is the Gitea user/org that owns the images. GITEA_REGISTRY = 'git.therogersfamily.tech' GITEA_OWNER = 'josh' // Jenkins credential ID for the Gitea push. Type: giteaPersonalAccessToken // (the gitea-personal-access-token plugin). The PAT must have at least // `write:package` scope. The token is bound as a Secret String at push time // and fed to `docker login --password-stdin` using GITEA_OWNER as the // username. GITEA_CREDENTIALS = 'gitea-ci' BACKEND_IMAGE = "${GITEA_REGISTRY}/${GITEA_OWNER}/yeschef-api" FRONTEND_IMAGE = "${GITEA_REGISTRY}/${GITEA_OWNER}/yeschef-web" IMAGE_TAG = "${env.BUILD_NUMBER}" // Keep NuGet / npm caches inside the workspace so they survive across // docker.inside containers without needing a shared host mount. DOTNET_CLI_TELEMETRY_OPTOUT = '1' NUGET_PACKAGES = "${env.WORKSPACE}/.nuget" npm_config_cache = "${env.WORKSPACE}/.npm" } stages { stage('Checkout') { steps { checkout scm } } stage('Backend: restore & build') { agent { docker { image "${DOTNET_SDK_IMAGE}" reuseNode true } } steps { sh 'dotnet --info' sh 'dotnet restore src/backend/YesChef.slnx' sh 'dotnet build src/backend/YesChef.slnx -c Release --no-restore' } } stage('Backend: unit tests') { agent { docker { image "${DOTNET_SDK_IMAGE}" reuseNode true } } steps { // global.json lives in src/backend and switches dotnet test to MTP mode, // which requires --project (positional project paths are rejected). dir('src/backend') { sh ''' dotnet test \ --project YesChef.Api.UnitTests/YesChef.Api.UnitTests.csproj \ -c Release --no-build \ --report-trx --results-directory ../../TestResults/backend-unit ''' } } post { always { junit allowEmptyResults: true, testResults: 'TestResults/backend-unit/**/*.trx' } } } stage('Backend: integration tests') { steps { script { // Sidecar Postgres reachable from the build container at host `postgres`. // Testcontainers can still spin up additional DBs because the host docker // socket is mounted into the SDK container below. docker.image("${POSTGRES_IMAGE}").withRun( "-e POSTGRES_DB=${POSTGRES_DB}" + " -e POSTGRES_USER=${POSTGRES_USER}" + " -e POSTGRES_PASSWORD=${POSTGRES_PASSWORD}" ) { pg -> // Wait for the DB to accept connections. docker.image("${POSTGRES_IMAGE}").inside("--link ${pg.id}:postgres") { sh ''' for i in $(seq 1 30); do if pg_isready -h postgres -U yeschef >/dev/null 2>&1; then echo "postgres is ready" exit 0 fi sleep 2 done echo "postgres did not become ready in time" >&2 exit 1 ''' } docker.image("${DOTNET_SDK_IMAGE}").inside( "--link ${pg.id}:postgres" + " -v /var/run/docker.sock:/var/run/docker.sock" + " -e ConnectionStrings__DefaultConnection=Host=postgres;Port=5432;Database=${POSTGRES_DB};Username=${POSTGRES_USER};Password=${POSTGRES_PASSWORD}" ) { dir('src/backend') { sh ''' dotnet test \ --project YesChef.Api.IntegrationTests/YesChef.Api.IntegrationTests.csproj \ -c Release \ --report-trx --results-directory ../../TestResults/backend-integration ''' } } } } } post { always { junit allowEmptyResults: true, testResults: 'TestResults/backend-integration/**/*.trx' } } } stage('Frontend') { agent { docker { image "${NODE_IMAGE}" reuseNode true } } stages { stage('Install') { steps { dir('src/frontend') { sh 'npm ci' } } } stage('Type check') { steps { dir('src/frontend') { sh 'npm run check' } } } stage('Unit tests') { steps { dir('src/frontend') { sh 'npm run test:unit' } } } stage('Build') { steps { dir('src/frontend') { sh 'npm run build' } } } } } stage('Docker images') { steps { script { def backendImg = docker.build("${BACKEND_IMAGE}:${IMAGE_TAG}", 'src/backend/YesChef.Api') def frontendImg = docker.build("${FRONTEND_IMAGE}:${IMAGE_TAG}", 'src/frontend') backendImg.tag('latest') frontendImg.tag('latest') // Gitea PAT credential (giteaPersonalAccessToken) — exposes the // token as a Secret String, so bind with `string` and feed it to // docker login on stdin. Username for the package registry is the // Gitea owner. withCredentials([string(credentialsId: "${GITEA_CREDENTIALS}", variable: 'GITEA_TOKEN')]) { sh """ set +x echo "\$GITEA_TOKEN" | docker login ${GITEA_REGISTRY} -u ${GITEA_OWNER} --password-stdin """ try { sh "docker push ${BACKEND_IMAGE}:${IMAGE_TAG}" sh "docker push ${BACKEND_IMAGE}:latest" sh "docker push ${FRONTEND_IMAGE}:${IMAGE_TAG}" sh "docker push ${FRONTEND_IMAGE}:latest" } finally { sh "docker logout ${GITEA_REGISTRY} || true" } } } } } } post { always { archiveArtifacts artifacts: 'TestResults/**/*.trx', allowEmptyArchive: true, fingerprint: false cleanWs(deleteDirs: true, notFailBuild: true) } } }