From c76969522dcfb2403f3bdba4e25a86efe230f0e9 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sun, 20 Sep 2026 02:57:52 +0200 Subject: [PATCH] ci: deploy demo.bulwarkmail.org from here instead of Watchtower The demo container followed ghcr.io/bulwarkmail/webmail:latest through Watchtower, so a release reached it silently and there was no way to see, trigger or undo it. It is now a deployment of this repo: - .github/workflows/deploy-demo.yml runs after "Publish Docker Image on Release" succeeds, or on demand with a tag (`gh workflow run deploy-demo.yml -f tag=1.10.0`, or action=rollback). It only talks to the server; nothing is built. - deploy/demo-deploy.sh is the forced command behind the deploy key (/usr/local/bin/bulwark-demo-deploy): pulls the tag, recreates the container with the demo mounts, checks /api/health and goes back to the image it replaced if that fails. A pre-release moves no :latest, so the deploy after one finds the same image and does nothing. Watchtower has been recreated without bulwark-demo; it still updates the two rbm.systems containers. --- .github/workflows/deploy-demo.yml | 89 +++++++++++++++++++ deploy/demo-deploy.sh | 138 ++++++++++++++++++++++++++++++ 2 files changed, 227 insertions(+) create mode 100644 .github/workflows/deploy-demo.yml create mode 100644 deploy/demo-deploy.sh diff --git a/.github/workflows/deploy-demo.yml b/.github/workflows/deploy-demo.yml new file mode 100644 index 000000000..c72f6dcf4 --- /dev/null +++ b/.github/workflows/deploy-demo.yml @@ -0,0 +1,89 @@ +name: Deploy demo + +# Keeps https://demo.bulwarkmail.org on the published release image. It used to +# follow ghcr.io/bulwarkmail/webmail:latest through Watchtower; now the deploy +# is a step of its own, so it shows up under Deployments, can be triggered by +# hand and can be rolled back. +# +# Runs after "Publish Docker Image on Release" succeeds (the image has to exist +# before the server can pull it), or on demand: +# +# gh workflow run deploy-demo.yml -f action=deploy -f tag=1.10.0 +# gh workflow run deploy-demo.yml -f action=rollback +# +# Nothing is built here. The server script /usr/local/bin/bulwark-demo-deploy +# (deploy/demo-deploy.sh) pulls the tag, recreates the container, health-checks +# it and rolls back on failure. A pre-release publishes no :latest, so the +# deploy that follows it finds the same image and does nothing. + +on: + workflow_run: + workflows: ["Publish Docker Image on Release"] + types: [completed] + workflow_dispatch: + inputs: + action: + description: "What to do" + type: choice + options: [deploy, rollback] + default: deploy + tag: + description: "Image tag to deploy" + type: string + default: latest + +permissions: + contents: read + +concurrency: + group: deploy-demo + cancel-in-progress: false + +env: + DEPLOY_HOST: 62.176.249.204 + DEPLOY_USER: ubuntu + # ED25519 host key of mail.rath.li (SHA256:KJU3AEoj23/nBmHDF5w8o+wfRQcQTLdg3HHBrmlMdSQ). + DEPLOY_HOST_KEY: "62.176.249.204 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINOvbgUpI8cbxGf9nLOZlaG+0xd/bkJosDpBTyMxuurC" + +jobs: + deploy: + name: ${{ inputs.action == 'rollback' && 'Roll back' || 'Deploy' }} + if: github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' + runs-on: ubuntu-24.04 + timeout-minutes: 10 + env: + # A workflow_run has no inputs; it always deploys the release tag. + ACTION: ${{ inputs.action || 'deploy' }} + TAG: ${{ inputs.tag || 'latest' }} + environment: + name: demo + url: https://demo.bulwarkmail.org + steps: + - name: Set up SSH + env: + DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }} + run: | + install -m 700 -d ~/.ssh + printf '%s\n' "$DEPLOY_SSH_KEY" > ~/.ssh/deploy_key && chmod 600 ~/.ssh/deploy_key + printf '%s\n' "$DEPLOY_HOST_KEY" > ~/.ssh/known_hosts + + - name: Deploy + if: env.ACTION == 'deploy' + run: ssh -i ~/.ssh/deploy_key -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes "$DEPLOY_USER@$DEPLOY_HOST" "deploy $TAG" + + - name: Roll back + if: env.ACTION == 'rollback' + run: ssh -i ~/.ssh/deploy_key -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes "$DEPLOY_USER@$DEPLOY_HOST" rollback + + - name: Check the live demo + run: | + for i in $(seq 1 10); do + code=$(curl -s -o /dev/null -w '%{http_code}' https://demo.bulwarkmail.org/api/health) + [ "$code" = 200 ] && { echo "live: $code"; exit 0; } + sleep 3 + done + echo "the demo answered $code"; exit 1 + + - name: Show what is running + if: always() + run: ssh -i ~/.ssh/deploy_key -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes "$DEPLOY_USER@$DEPLOY_HOST" status diff --git a/deploy/demo-deploy.sh b/deploy/demo-deploy.sh new file mode 100644 index 000000000..6c588e885 --- /dev/null +++ b/deploy/demo-deploy.sh @@ -0,0 +1,138 @@ +#!/usr/bin/env bash +# Deploys the demo at https://demo.bulwarkmail.org on mail.rath.li. +# +# The demo is a single container off the published release image, so there is +# nothing to build here: this pulls a tag from GHCR and recreates the +# container. Installed as /usr/local/bin/bulwark-demo-deploy and run as the +# `ubuntu` user through a forced command on the deploy key in +# ~ubuntu/.ssh/authorized_keys: +# +# command="/usr/local/bin/bulwark-demo-deploy",restrict ssh-ed25519 AAAA... demo deploy +# +# so the key can do nothing but this. Re-install it by hand when this file +# changes. The requested action comes in SSH_ORIGINAL_COMMAND: +# +# deploy [tag] pull ghcr.io/bulwarkmail/webmail: (default latest), +# recreate the container, check it, roll back on failure +# rollback go back to the image the last deploy replaced +# status print the running image, its version and health +# +# Replaces Watchtower for this container: the webmail repo deploys here after +# it publishes a release image (.github/workflows/deploy-demo.yml). The two +# rbm.systems containers are still on Watchtower's watch list. +set -euo pipefail + +IMAGE=ghcr.io/bulwarkmail/webmail +NAME=bulwark-demo +PORT=3002 +DATA=/opt/bulwark-demo-data +# The image this deploy replaced, kept tagged so it survives image pruning. +PREVIOUS=bulwark-demo:previous +STATE=/opt/bulwark-demo-deploy + +log() { echo "[demo-deploy] $*"; } + +docker_() { sudo -n docker "$@"; } + +image_id() { docker_ image inspect -f '{{.Id}}' "$1" 2>/dev/null || true; } + +running_image_id() { docker_ inspect -f '{{.Image}}' "$NAME" 2>/dev/null || true; } + +version_of() { docker_ image inspect -f '{{index .Config.Labels "org.opencontainers.image.version"}}' "$1" 2>/dev/null || true; } + +# Recreate the container from $1. Mirrors the original `docker run`: demo mode, +# host port 3002, and the three bind mounts that keep the demo past the setup +# wizard (admin/config.json holds setupComplete). +run_container() { + docker_ rm -f "$NAME" >/dev/null 2>&1 || true + docker_ run -d \ + --name "$NAME" \ + --restart unless-stopped \ + -p "${PORT}:3000" \ + -e DEMO_MODE=true \ + -v "$DATA/admin:/app/data/admin" \ + -v "$DATA/settings:/app/data/settings" \ + -v "$DATA/telemetry:/app/data/telemetry" \ + "$1" >/dev/null +} + +healthy() { + local i + for i in $(seq 1 30); do + if curl -fsS -o /dev/null "http://127.0.0.1:${PORT}/api/health"; then + return 0 + fi + sleep 2 + done + return 1 +} + +cmd=${SSH_ORIGINAL_COMMAND:-${1:-}} +mkdir -p "$STATE" +exec 9>"$STATE/.deploy.lock" +flock -n 9 || { log "another deploy is running"; exit 1; } + +case "$cmd" in + deploy|deploy\ *) + tag=${cmd#deploy} + tag=${tag# } + tag=${tag:-latest} + [[ "$tag" =~ ^[A-Za-z0-9._-]{1,128}$ ]] || { log "bad tag: $tag"; exit 2; } + + log "pulling $IMAGE:$tag" + docker_ pull -q "$IMAGE:$tag" >/dev/null + + new=$(image_id "$IMAGE:$tag") + [[ -n "$new" ]] || { log "no such image after pull"; exit 3; } + old=$(running_image_id) + + if [[ "$new" == "$old" ]] && healthy; then + log "already on $(version_of "$new") ($tag), nothing to do" + exit 0 + fi + + [[ -n "$old" ]] && docker_ tag "$old" "$PREVIOUS" + run_container "$IMAGE:$tag" + if healthy; then + log "live: $(version_of "$new") ($tag)" + else + log "health check failed" + if [[ -n "$old" ]]; then + run_container "$PREVIOUS" + healthy && log "back on $(version_of "$old")" || log "rollback is not answering either" + fi + exit 4 + fi + + # Only untagged leftovers; $PREVIOUS stays because it is tagged. + docker_ image prune -f >/dev/null || true + ;; + + rollback) + prev=$(image_id "$PREVIOUS") + [[ -n "$prev" ]] || { log "no earlier image to roll back to"; exit 5; } + cur=$(running_image_id) + run_container "$PREVIOUS" + # Point $PREVIOUS at what we just left, so a second rollback comes back. + [[ -n "$cur" ]] && docker_ tag "$cur" "$PREVIOUS" + healthy && log "rolled back to $(version_of "$prev")" || { log "rolled back, but the health check failed"; exit 4; } + ;; + + status) + cur=$(running_image_id) + if [[ -n "$cur" ]]; then + echo "running: $(version_of "$cur") ($(docker_ inspect -f '{{.Config.Image}}' "$NAME")) ${cur:7:12}" + echo "state: $(docker_ inspect -f '{{.State.Status}}' "$NAME")" + else + echo "running: none" + fi + prev=$(image_id "$PREVIOUS") + [[ -n "$prev" ]] && echo "previous: $(version_of "$prev") ${prev:7:12}" || echo "previous: none" + curl -fsS -o /dev/null -w "health: %{http_code}\n" "http://127.0.0.1:${PORT}/api/health" || true + ;; + + *) + echo "usage: deploy [tag] | rollback | status" >&2 + exit 2 + ;; +esac