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.
This commit is contained in:
Linus Rath
2026-09-20 02:57:52 +02:00
parent 4790101a3b
commit c76969522d
2 changed files with 227 additions and 0 deletions
+89
View File
@@ -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
+138
View File
@@ -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:<tag> (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