|
| 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