mailu-update #2
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: mailu-update | |
| # Keep the self-hosted Mailu stack on the latest patch of its pinned series. | |
| # | |
| # The Mailu compose stack (deploy/mailu) pins the FLOATING series tags | |
| # (ghcr.io/mailu/*:2024.06). Upstream ships frequent patch releases within that | |
| # series (2024.06.NN) with security fixes, but a running host only picks them up | |
| # when someone runs `docker compose pull`. Left alone, the box drifts behind. | |
| # | |
| # This workflow SSHes to the droplet on a weekly schedule (and on demand), backs | |
| # up the irreplaceable state (DKIM keys + admin DB), pulls the newest images for | |
| # the pinned series, recreates the containers, and health-checks the result. | |
| # It stays WITHIN the pinned series on purpose: crossing to a future series | |
| # (e.g. a 2025.xx) can carry DB migrations and config changes, so that is a | |
| # deliberate PR that edits the tags in deploy/mailu/*.yml — not an auto-pull. | |
| # | |
| # The agentbbs Go binary already redeploys on every push (deploy.yml) and the | |
| # rootless podman pods rebuild from upstream base images, so this workflow is the | |
| # missing piece: it covers the one long-lived docker-compose stack on the box. | |
| # | |
| # Reuses the same repo secrets as deploy.yml: | |
| # DEPLOY_SSH_KEY private key whose public half is in the droplet admin user's | |
| # authorized_keys | |
| # DEPLOY_HOST bbs.profullstack.com (or the droplet IP) | |
| # DEPLOY_USER admin SSH user (default: root) | |
| # DEPLOY_PORT admin SSH port (default: 2202) | |
| on: | |
| schedule: | |
| # 06:30 UTC every Monday. Off-peak; adjust as you like. | |
| - cron: '30 6 * * 1' | |
| workflow_dispatch: | |
| inputs: | |
| prune: | |
| description: 'Prune dangling images after the update' | |
| type: boolean | |
| default: true | |
| # Share deploy.yml's concurrency group so an image pull can never race a code | |
| # deploy on the same host — whichever starts first runs to completion, the other | |
| # queues behind it. | |
| concurrency: | |
| group: deploy-production | |
| cancel-in-progress: false | |
| permissions: | |
| contents: read | |
| jobs: | |
| update: | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Configure SSH | |
| env: | |
| DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }} | |
| DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }} | |
| DEPLOY_PORT: ${{ secrets.DEPLOY_PORT || '2202' }} | |
| run: | | |
| test -n "$DEPLOY_SSH_KEY" || { echo "::error::DEPLOY_SSH_KEY secret is not set"; exit 1; } | |
| test -n "$DEPLOY_HOST" || { echo "::error::DEPLOY_HOST secret is not set"; exit 1; } | |
| install -d -m 700 ~/.ssh | |
| printf '%s\n' "$DEPLOY_SSH_KEY" > ~/.ssh/id_deploy | |
| chmod 600 ~/.ssh/id_deploy | |
| ssh-keyscan -p "$DEPLOY_PORT" -H "$DEPLOY_HOST" >> ~/.ssh/known_hosts 2>/dev/null | |
| - name: Pull latest Mailu images, recreate, health-check | |
| env: | |
| DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }} | |
| DEPLOY_USER: ${{ secrets.DEPLOY_USER || 'root' }} | |
| DEPLOY_PORT: ${{ secrets.DEPLOY_PORT || '2202' }} | |
| PRUNE: ${{ github.event_name == 'schedule' && 'true' || inputs.prune }} | |
| run: | | |
| ssh -i ~/.ssh/id_deploy -p "$DEPLOY_PORT" \ | |
| -o BatchMode=yes -o StrictHostKeyChecking=yes \ | |
| "${DEPLOY_USER}@${DEPLOY_HOST}" \ | |
| "sudo -n env PRUNE=$(printf %q "$PRUNE") bash -s" <<'REMOTE' | |
| set -euo pipefail | |
| MAILU_DIR=/opt/agentbbs/deploy/mailu | |
| cd "$MAILU_DIR" | |
| if [ ! -f mailu.env ]; then | |
| echo "::notice::mailu.env not present at ${MAILU_DIR} — stack not deployed, nothing to update" | |
| exit 0 | |
| fi | |
| echo "== images before ==" | |
| docker compose images | |
| # Back up the irreplaceable bits before touching anything: the DKIM | |
| # signing keys (data/dkim) and the admin database (data/data, sqlite). | |
| # Mailboxes (data/mail) are large and are NOT touched by an image pull, | |
| # so we deliberately skip them here — back those up separately. | |
| ts="$(date -u +%Y%m%dT%H%M%SZ)" | |
| install -d -m 700 backups | |
| tar czf "backups/mailu-state-${ts}.tgz" \ | |
| $( [ -d data/dkim ] && echo data/dkim ) \ | |
| $( [ -d data/data ] && echo data/data ) 2>/dev/null || true | |
| echo "::notice::backed up DKIM + admin DB to backups/mailu-state-${ts}.tgz" | |
| # Keep only the 10 most recent state backups. | |
| ls -1t backups/mailu-state-*.tgz 2>/dev/null | tail -n +11 | xargs -r rm -f | |
| echo "== pulling latest patch for the pinned series ==" | |
| docker compose pull | |
| echo "== recreating containers ==" | |
| docker compose up -d | |
| # Give services a moment, then verify. Mailu's front serves HTTP on | |
| # 127.0.0.1:8080 (Caddy fronts it); a reachable webmail means the | |
| # stack came back up. | |
| ok=0 | |
| code="" | |
| for i in $(seq 1 30); do | |
| code="$(curl -fsS -o /dev/null -w '%{http_code}' --max-time 5 http://127.0.0.1:8080/ || true)" | |
| case "$code" in | |
| 2??|3??) ok=1; break ;; | |
| esac | |
| sleep 3 | |
| done | |
| echo "== compose status ==" | |
| docker compose ps | |
| if [ "$ok" != "1" ]; then | |
| 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." | |
| exit 1 | |
| fi | |
| echo "::notice::Mailu front is answering (HTTP ${code}) after update" | |
| echo "== images after ==" | |
| docker compose images | |
| if [ "${PRUNE:-false}" = "true" ]; then | |
| echo "== pruning dangling images ==" | |
| docker image prune -f | |
| fi | |
| REMOTE |