From 7955bed4a980c1f91b56b0ae9bcbb719488d4921 Mon Sep 17 00:00:00 2001 From: robert Date: Tue, 7 Jul 2026 12:43:46 -0400 Subject: [PATCH] Add Jenkins CI and Portainer stack for image deployment Jenkinsfile builds and pushes both the firebase (nginx/static) and sqlite (caddy+node) images to thinkserver:5000, guarded to main only. docker/portainer-stack.yml deploys the prebuilt sqlite image from the registry behind the external TLS-terminating nginx. Co-Authored-By: Claude Opus 4.8 --- Jenkinsfile | 17 ++++++++++++++--- docker/portainer-stack.yml | 27 +++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 3 deletions(-) create mode 100644 docker/portainer-stack.yml diff --git a/Jenkinsfile b/Jenkinsfile index 900e7aa..d2b4be2 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -49,13 +49,21 @@ pipeline { checkout scm script { env.SHORT_SHA = sh(returnStdout: true, script: 'git rev-parse --short=8 HEAD').trim() - echo "Building tag ${env.SHORT_SHA} (build #${env.BUILD_NUMBER})" + // 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 { expression { return params.REGISTRY_AUTH } } + when { allOf { expression { env.IS_MAIN == 'true' }; expression { return params.REGISTRY_AUTH } } } steps { withCredentials([usernamePassword(credentialsId: 'thinkserver-registry', usernameVariable: 'REG_USER', @@ -66,6 +74,7 @@ pipeline { } 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 @@ -83,6 +92,7 @@ pipeline { } 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. @@ -97,7 +107,7 @@ pipeline { } stage('Tag latest') { - when { expression { return params.PUSH_LATEST } } + when { allOf { expression { env.IS_MAIN == 'true' }; expression { return params.PUSH_LATEST } } } steps { sh ''' docker tag "$FIREBASE_IMAGE:$SHORT_SHA" "$FIREBASE_IMAGE:latest" @@ -107,6 +117,7 @@ pipeline { } stage('Push') { + when { expression { env.IS_MAIN == 'true' } } steps { sh ''' docker push "$FIREBASE_IMAGE:$SHORT_SHA" diff --git a/docker/portainer-stack.yml b/docker/portainer-stack.yml new file mode 100644 index 0000000..ae9d878 --- /dev/null +++ b/docker/portainer-stack.yml @@ -0,0 +1,27 @@ +# docker/portainer-stack.yml — Portainer stack for the SQLite/server build. +# Deploys the prebuilt image from the local registry (built + pushed by Jenkins). +# Paste into Portainer's web editor (Stacks -> Add stack -> Web editor). +# +# TLS is terminated by the external nginx, which should reverse_proxy to +# http://:8080 (and pass the WebSocket upgrade headers for /ws). +services: + app: + image: thinkserver:5000/ttrpg-tracker-sqlite:latest + container_name: ttrpg-tracker + ports: + - "8080:80" # host:container — nginx upstream is :8080 + volumes: + - app-data:/data # SQLite DB lives here; back this up + environment: + - DB_PATH=/data/tracker.sqlite + - PORT=4001 # node backend port (internal; caddy proxies to it) + restart: unless-stopped + healthcheck: + test: ["CMD", "wget", "-qO-", "http://localhost:80/"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s + +volumes: + app-data: