Skip to content

Commit a8dcc75

Browse files
ralyodioclaude
andcommitted
ci: add mailu-update workflow to keep the mail stack current
The deploy/mailu compose stack pins the floating series tags (ghcr.io/mailu/*:2024.06); patch releases within the series only land when someone runs `docker compose pull`, so the box drifts behind on security fixes. Add a scheduled (weekly) + on-demand workflow that SSHes to the droplet (reusing deploy.yml's DEPLOY_* secrets), backs up DKIM keys + the admin DB, pulls the latest images for the pinned series, recreates the containers, and health-checks the Mailu front on 127.0.0.1:8080. Shares deploy.yml's concurrency group so it never races a code deploy. Stays within the pinned series on purpose — crossing to a future series stays a deliberate PR. Co-Authored-By: Claude Opus 4.8 <[email protected]>
1 parent 28ad368 commit a8dcc75

1 file changed

Lines changed: 137 additions & 0 deletions

File tree

.github/workflows/mailu-update.yml

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
name: mailu-update
2+
3+
# Keep the self-hosted Mailu stack on the latest patch of its pinned series.
4+
#
5+
# The Mailu compose stack (deploy/mailu) pins the FLOATING series tags
6+
# (ghcr.io/mailu/*:2024.06). Upstream ships frequent patch releases within that
7+
# series (2024.06.NN) with security fixes, but a running host only picks them up
8+
# when someone runs `docker compose pull`. Left alone, the box drifts behind.
9+
#
10+
# This workflow SSHes to the droplet on a weekly schedule (and on demand), backs
11+
# up the irreplaceable state (DKIM keys + admin DB), pulls the newest images for
12+
# the pinned series, recreates the containers, and health-checks the result.
13+
# It stays WITHIN the pinned series on purpose: crossing to a future series
14+
# (e.g. a 2025.xx) can carry DB migrations and config changes, so that is a
15+
# deliberate PR that edits the tags in deploy/mailu/*.yml — not an auto-pull.
16+
#
17+
# The agentbbs Go binary already redeploys on every push (deploy.yml) and the
18+
# rootless podman pods rebuild from upstream base images, so this workflow is the
19+
# missing piece: it covers the one long-lived docker-compose stack on the box.
20+
#
21+
# Reuses the same repo secrets as deploy.yml:
22+
# DEPLOY_SSH_KEY private key whose public half is in the droplet admin user's
23+
# authorized_keys
24+
# DEPLOY_HOST bbs.profullstack.com (or the droplet IP)
25+
# DEPLOY_USER admin SSH user (default: root)
26+
# DEPLOY_PORT admin SSH port (default: 2202)
27+
28+
on:
29+
schedule:
30+
# 06:30 UTC every Monday. Off-peak; adjust as you like.
31+
- cron: '30 6 * * 1'
32+
workflow_dispatch:
33+
inputs:
34+
prune:
35+
description: 'Prune dangling images after the update'
36+
type: boolean
37+
default: true
38+
39+
# Share deploy.yml's concurrency group so an image pull can never race a code
40+
# deploy on the same host — whichever starts first runs to completion, the other
41+
# queues behind it.
42+
concurrency:
43+
group: deploy-production
44+
cancel-in-progress: false
45+
46+
permissions:
47+
contents: read
48+
49+
jobs:
50+
update:
51+
runs-on: ubuntu-latest
52+
steps:
53+
- name: Configure SSH
54+
env:
55+
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
56+
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
57+
DEPLOY_PORT: ${{ secrets.DEPLOY_PORT || '2202' }}
58+
run: |
59+
test -n "$DEPLOY_SSH_KEY" || { echo "::error::DEPLOY_SSH_KEY secret is not set"; exit 1; }
60+
test -n "$DEPLOY_HOST" || { echo "::error::DEPLOY_HOST secret is not set"; exit 1; }
61+
install -d -m 700 ~/.ssh
62+
printf '%s\n' "$DEPLOY_SSH_KEY" > ~/.ssh/id_deploy
63+
chmod 600 ~/.ssh/id_deploy
64+
ssh-keyscan -p "$DEPLOY_PORT" -H "$DEPLOY_HOST" >> ~/.ssh/known_hosts 2>/dev/null
65+
66+
- name: Pull latest Mailu images, recreate, health-check
67+
env:
68+
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
69+
DEPLOY_USER: ${{ secrets.DEPLOY_USER || 'root' }}
70+
DEPLOY_PORT: ${{ secrets.DEPLOY_PORT || '2202' }}
71+
PRUNE: ${{ github.event_name == 'schedule' && 'true' || inputs.prune }}
72+
run: |
73+
ssh -i ~/.ssh/id_deploy -p "$DEPLOY_PORT" \
74+
-o BatchMode=yes -o StrictHostKeyChecking=yes \
75+
"${DEPLOY_USER}@${DEPLOY_HOST}" \
76+
"sudo -n env PRUNE=$(printf %q "$PRUNE") bash -s" <<'REMOTE'
77+
set -euo pipefail
78+
MAILU_DIR=/opt/agentbbs/deploy/mailu
79+
cd "$MAILU_DIR"
80+
81+
if [ ! -f mailu.env ]; then
82+
echo "::notice::mailu.env not present at ${MAILU_DIR} — stack not deployed, nothing to update"
83+
exit 0
84+
fi
85+
86+
echo "== images before =="
87+
docker compose images
88+
89+
# Back up the irreplaceable bits before touching anything: the DKIM
90+
# signing keys (data/dkim) and the admin database (data/data, sqlite).
91+
# Mailboxes (data/mail) are large and are NOT touched by an image pull,
92+
# so we deliberately skip them here — back those up separately.
93+
ts="$(date -u +%Y%m%dT%H%M%SZ)"
94+
install -d -m 700 backups
95+
tar czf "backups/mailu-state-${ts}.tgz" \
96+
$( [ -d data/dkim ] && echo data/dkim ) \
97+
$( [ -d data/data ] && echo data/data ) 2>/dev/null || true
98+
echo "::notice::backed up DKIM + admin DB to backups/mailu-state-${ts}.tgz"
99+
# Keep only the 10 most recent state backups.
100+
ls -1t backups/mailu-state-*.tgz 2>/dev/null | tail -n +11 | xargs -r rm -f
101+
102+
echo "== pulling latest patch for the pinned series =="
103+
docker compose pull
104+
105+
echo "== recreating containers =="
106+
docker compose up -d
107+
108+
# Give services a moment, then verify. Mailu's front serves HTTP on
109+
# 127.0.0.1:8080 (Caddy fronts it); a reachable webmail means the
110+
# stack came back up.
111+
ok=0
112+
code=""
113+
for i in $(seq 1 30); do
114+
code="$(curl -fsS -o /dev/null -w '%{http_code}' --max-time 5 http://127.0.0.1:8080/ || true)"
115+
case "$code" in
116+
2??|3??) ok=1; break ;;
117+
esac
118+
sleep 3
119+
done
120+
121+
echo "== compose status =="
122+
docker compose ps
123+
124+
if [ "$ok" != "1" ]; then
125+
echo "::error::Mailu front did not answer on 127.0.0.1:8080 after update — check 'docker compose logs' in ${MAILU_DIR}. Restore from backups/mailu-state-${ts}.tgz if needed."
126+
exit 1
127+
fi
128+
echo "::notice::Mailu front is answering (HTTP ${code}) after update"
129+
130+
echo "== images after =="
131+
docker compose images
132+
133+
if [ "${PRUNE:-false}" = "true" ]; then
134+
echo "== pruning dangling images =="
135+
docker image prune -f
136+
fi
137+
REMOTE

0 commit comments

Comments
 (0)