From acd95a203314dbcbbcbbfab0fd7a0759e6077a46 Mon Sep 17 00:00:00 2001 From: Jake Runyan Date: Sun, 21 Jun 2026 20:10:51 -0700 Subject: [PATCH] Add Jenkinsfile --- Jenkinsfile | 149 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 149 insertions(+) create mode 100644 Jenkinsfile diff --git a/Jenkinsfile b/Jenkinsfile new file mode 100644 index 0000000..ce9ef2d --- /dev/null +++ b/Jenkinsfile @@ -0,0 +1,149 @@ +pipeline { + agent any + + environment { + // Secret: Discord webhook used by the post-build notifier. + DISCORD_WEBHOOK = credentials('discord-pws-builds-channel-webhook') + + // Deployment identity / topology (non-secret, declared here for auditability). + COMPOSE_PROJECT_NAME = 'jakeswestcoast' + APP_CONTAINER = 'jakeswestcoast' + DEPLOY_NETWORK = 'traefik' + + // Clean, pinned image used to run static checks in isolation. + LINT_IMAGE = 'node:18-alpine' + + // Health/smoke probing (served internally on port 80, reached via the container). + HEALTH_URL = 'http://localhost:80/' + SMOKE_MARKER = 'id="root"' + } + + stages { + stage('Checkout') { + steps { + checkout scm + } + } + + stage('Preflight') { + steps { + sh ''' + set -e + [ -n "$DISCORD_WEBHOOK" ] || { echo "ERROR: DISCORD_WEBHOOK credential is missing."; exit 1; } + docker network inspect "$DEPLOY_NETWORK" >/dev/null 2>&1 \ + || { echo "ERROR: external network '$DEPLOY_NETWORK' does not exist."; exit 1; } + docker compose config -q \ + || { echo "ERROR: docker-compose configuration is invalid."; exit 1; } + ''' + } + } + + stage('Lint & Type-check') { + steps { + // Run checks in a throwaway container so host state can't mask errors. + sh ''' + set -e + docker run --rm -v "$WORKSPACE:/app" -w /app "$LINT_IMAGE" \ + sh -c "npm install --no-audit --no-fund && npx eslint src/" \ + || { echo "ERROR: static checks failed."; exit 1; } + ''' + } + } + + stage('Teardown') { + steps { + sh ''' + set -e + docker compose down --remove-orphans \ + || { echo "ERROR: failed to tear down the previous deployment."; exit 1; } + ''' + } + } + + stage('Build & Deploy') { + steps { + sh ''' + set -e + docker compose up -d --build \ + || { echo "ERROR: build and deploy failed."; exit 1; } + ''' + } + } + + stage('Health Check') { + steps { + // Poll until the container serves, failing fast if it exits or never becomes ready. + sh ''' + set -e + for i in $(seq 1 20); do + running=$(docker inspect -f '{{.State.Running}}' "$APP_CONTAINER" 2>/dev/null || echo "false") + [ "$running" = "true" ] || { echo "ERROR: container '$APP_CONTAINER' is not running."; exit 1; } + if docker exec "$APP_CONTAINER" wget -q -O /dev/null "$HEALTH_URL"; then + echo "App is live." + exit 0 + fi + echo "Waiting for app to become ready ($i)..." + sleep 3 + done + echo "ERROR: app did not become healthy in time." + exit 1 + ''' + } + } + + stage('Smoke Test') { + steps { + sh ''' + set -e + body=$(docker exec "$APP_CONTAINER" wget -q -O- "$HEALTH_URL") \ + || { echo "ERROR: smoke request failed."; exit 1; } + echo "$body" | grep -q "$SMOKE_MARKER" \ + || { echo "ERROR: smoke test did not find expected content ('$SMOKE_MARKER')."; exit 1; } + echo "Smoke test passed." + ''' + } + } + } + + post { + always { + script { + def result = currentBuild.currentResult + def emoji = result == 'SUCCESS' ? ':green_circle:' : (result == 'FAILURE' ? ':red_circle:' : ':yellow_circle:') + def branch = env.GIT_BRANCH ?: env.BRANCH_NAME ?: 'Main/Manual' + def duration = currentBuild.durationString.replace(' and no weeks', '').replace(' and counting', '') + + def commitLines = [] + for (changeSet in currentBuild.changeSets) { + for (commit in changeSet.items) { + commitLines.add("> ${commit.msg} (by *${commit.author.fullName}*)") + } + } + def commits = commitLines ? commitLines.join('\n') : 'No recent changes detected.' + + def discordDescription = """**Status:** ${emoji} ${result} +**Branch:** `${branch}` +**Duration:** :stopwatch: ${duration} + +**Commits:** +${commits}""" + + discordSend( + webhookURL: env.DISCORD_WEBHOOK, + title: "📦 Build Alert: ${env.JOB_NAME} [Build #${env.BUILD_NUMBER}]", + link: "${env.BUILD_URL}", + result: "${currentBuild.currentResult}", + description: discordDescription + ) + } + } + failure { + sh ''' + echo "===== docker compose ps =====" + docker compose ps || true + echo "===== recent logs =====" + docker compose logs --no-color --tail=200 || true + ''' + } + } +}