// Jenkinsfile — build + publish both images to the thinkserver:5000 registry. // // ttrpg-tracker-firebase : root Dockerfile, nginx serving the static SPA, // Firebase storage (config baked in at build time). // ttrpg-tracker-sqlite : docker/Dockerfile, Caddy + Node, server/SQLite // storage (REACT_APP_STORAGE=server). // // Requirements on the Jenkins agent: // * Docker CLI available, agent user in the docker group (or DooD socket). // * thinkserver:5000 reachable and trusted by the Docker daemon. If it is a // plain-HTTP registry, add it to /etc/docker/daemon.json on the AGENT host: // { "insecure-registries": ["thinkserver:5000"] } (then restart docker) // // Jenkins credentials this pipeline expects: // * ttrpg-firebase-env-local (Secret file) — the .env.local contents with // REACT_APP_FIREBASE_* values. Baked into the firebase image at build time. // * thinkserver-registry (Username/pw) — OPTIONAL. Only needed if the // registry requires auth; the login stage is guarded below. pipeline { agent any options { timestamps() disableConcurrentBuilds() timeout(time: 30, unit: 'MINUTES') } parameters { string(name: 'TRACKER_APP_ID', defaultValue: 'ttrpg-initiative-tracker-default', description: 'Firestore/app namespace baked into the SQLite image (REACT_APP_TRACKER_APP_ID).') booleanParam(name: 'PUSH_LATEST', defaultValue: true, description: 'Also tag and push :latest in addition to the commit-SHA tag.') booleanParam(name: 'REGISTRY_AUTH', defaultValue: false, description: 'Enable docker login using the thinkserver-registry credential.') booleanParam(name: 'REDEPLOY_PORTAINER', defaultValue: true, description: 'After pushing, POST to the Portainer stack webhooks to re-pull and redeploy.') } environment { REGISTRY = 'thinkserver:5000' FIREBASE_IMAGE = "${REGISTRY}/ttrpg-tracker-firebase" SQLITE_IMAGE = "${REGISTRY}/ttrpg-tracker-sqlite" DOCKER_BUILDKIT = '1' } stages { stage('Checkout') { steps { checkout scm script { env.SHORT_SHA = sh(returnStdout: true, script: 'git rev-parse --short=8 HEAD').trim() // Only build/push on main. BRANCH_NAME is set by Multibranch jobs; it // is unset in a plain "Pipeline from SCM" job, which already only ever // checks out main, so treat unset as main too. env.IS_MAIN = (env.BRANCH_NAME == null || env.BRANCH_NAME == 'main').toString() if (env.IS_MAIN != 'true') { echo "Branch '${env.BRANCH_NAME}' is not main — skipping image build/push." } else { echo "Building tag ${env.SHORT_SHA} (build #${env.BUILD_NUMBER})" } } } } stage('Registry login') { when { allOf { expression { env.IS_MAIN == 'true' }; expression { return params.REGISTRY_AUTH } } } steps { withCredentials([usernamePassword(credentialsId: 'thinkserver-registry', usernameVariable: 'REG_USER', passwordVariable: 'REG_PASS')]) { sh 'echo "$REG_PASS" | docker login "$REGISTRY" -u "$REG_USER" --password-stdin' } } } stage('Build firebase image') { when { expression { env.IS_MAIN == 'true' } } steps { // Firebase config is compiled into the static bundle, so it must exist // at build time. Inject .env.local from the Jenkins secret file, then // the root Dockerfile's `COPY .env.local .env` picks it up. withCredentials([file(credentialsId: 'ttrpg-firebase-env-local', variable: 'FIREBASE_ENV')]) { sh ''' cp "$FIREBASE_ENV" .env.local docker build \ -f Dockerfile \ -t "$FIREBASE_IMAGE:$SHORT_SHA" \ . ''' } } } stage('Build sqlite image') { when { expression { env.IS_MAIN == 'true' } } steps { // Server/SQLite build. BuildKit is required (syntax directive + cache // mount in docker/Dockerfile). Context is the repo root. sh ''' docker build \ -f docker/Dockerfile \ --build-arg REACT_APP_TRACKER_APP_ID="$TRACKER_APP_ID" \ -t "$SQLITE_IMAGE:$SHORT_SHA" \ . ''' } } stage('Tag latest') { when { allOf { expression { env.IS_MAIN == 'true' }; expression { return params.PUSH_LATEST } } } steps { sh ''' docker tag "$FIREBASE_IMAGE:$SHORT_SHA" "$FIREBASE_IMAGE:latest" docker tag "$SQLITE_IMAGE:$SHORT_SHA" "$SQLITE_IMAGE:latest" ''' } } stage('Push') { when { expression { env.IS_MAIN == 'true' } } steps { sh ''' docker push "$FIREBASE_IMAGE:$SHORT_SHA" docker push "$SQLITE_IMAGE:$SHORT_SHA" ''' script { if (params.PUSH_LATEST) { sh ''' docker push "$FIREBASE_IMAGE:latest" docker push "$SQLITE_IMAGE:latest" ''' } } } } stage('Redeploy (Portainer webhooks)') { when { allOf { expression { env.IS_MAIN == 'true' }; expression { return params.REDEPLOY_PORTAINER } } } steps { // Webhook URLs are Secret-text credentials, so they never appear in the // repo and Jenkins masks them in the console. They are bound to shell // env vars and referenced inside a single-quoted sh block, so Groovy // never interpolates the value into the pipeline log. withCredentials([ string(credentialsId: 'portainer-webhook-firebase', variable: 'WEBHOOK_FIREBASE'), string(credentialsId: 'portainer-webhook-sqlite', variable: 'WEBHOOK_SQLITE'), ]) { sh ''' set +x echo "Triggering Portainer redeploy (firebase stack)" curl -fsS -X POST --retry 3 --retry-delay 5 "$WEBHOOK_FIREBASE" echo "Triggering Portainer redeploy (sqlite stack)" curl -fsS -X POST --retry 3 --retry-delay 5 "$WEBHOOK_SQLITE" ''' } } } } post { always { // Never leave Firebase secrets in the workspace or log out cleanly. sh 'rm -f .env.local || true' sh 'docker logout "$REGISTRY" || true' } success { echo "Pushed ${FIREBASE_IMAGE}:${SHORT_SHA} and ${SQLITE_IMAGE}:${SHORT_SHA}" } } }