Add Jenkinsfile

This commit is contained in:
Jake Runyan 2026-06-21 20:10:51 -07:00
parent 9c8dde30c4
commit acd95a2033

149
Jenkinsfile vendored Normal file
View File

@ -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
'''
}
}
}