The Gitea-hosted CI/CD brief (SemVer tags, Actions pipelines, registry, deploy/rollback scripts, operator runbooks). Written before the NAS work and kept as-is; note that §5.2's pgBackRest/ZFS snapshot step does not apply to a Synology or QNAP target, where docs/nas-install.md substitutes verified pg_dump plus Btrfs snapshots and Hyper Backup/HBS3. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nux3LCu4zP9nih4BRhPyHw
12 KiB
Deployment & Release System — Agent Build Instructions
Companion to SPEC.md and DEV_ENVIRONMENT.md. This document tells you (the implementing agent) to build the complete versioning, CI/CD, and production-update system for this project. The human operator is not a DevOps specialist — your job is to make deployment a one-command, safe, reversible operation and to document the few manual steps clearly.
Everything lives inside the operator's own Gitea server (source, CI, and container registry). No GitHub, no external CI, no external registry.
0. What you are building (deliverables checklist)
Create all of the following, committed to the repo:
.gitea/workflows/ci.yml— test + build + push pipeline (runs on every push and PR)..gitea/workflows/release.yml— on a version tag: test, build images, push to Gitea registry, deploy to production.docker/Dockerfile.backendanddocker/Dockerfile.frontend— production images (multi-stage, slim).docker/docker-compose.prod.yml— the production stack, referencing images by pinned tag from the Gitea registry.deploy/deploy.sh— the script that runs ON the production server to apply an update safely.deploy/rollback.sh— one-command rollback to the previous version.backend/app/api/health.py—/api/healthzand/api/versionendpoints.deploy/VERSION— plain-text current version, written by the release pipeline.docs/deployment.md— a short operator runbook in plain language (how to release, how to roll back, what to do if it breaks). Write this for a non-expert.docs/gitea-setup.md— the one-time manual setup the operator must do in Gitea's UI (documented step by step, see §7).
Do not require any tool the operator must learn. The whole system is: push a tag → it deploys. Run one script → it rolls back.
1. Versioning rules (implement, don't deviate)
- SemVer git tags are the single source of truth:
v{MAJOR}.{MINOR}.{PATCH}(e.g.v1.4.2). - The tag name becomes the image tag. Images are never tagged
latestin production compose.latestmay exist as a convenience alias but production always pins an explicit version. /api/versionreturns the running version (read from a build-time envAPP_VERSIONbaked into the image)./api/healthzreturns 200 only when DB + Redis are reachable.- Every release is atomic (roll the whole tag) and reversible (redeploy the previous tag). Never hand-edit files on the production server to "patch" — always go through a tag.
- Tag creation is the human's explicit release action. Do NOT auto-tag from routine commits — routine commits run CI (test+build) but do not deploy. Only a
v*tag deploys.
2. Images (docker/Dockerfile.backend, docker/Dockerfile.frontend)
- Multi-stage builds. Backend: build deps in a builder stage, copy only the venv + app into a slim
python:3.11-slimruntime; run as a non-root user;ENV APP_VERSIONset via build arg. Frontend: build with Node 20, serve the static bundle from nginx-alpine (or have the API serve it — pick API-served only if simpler for a single node; default to a separatewebnginx image). - Backend image entrypoint supports three roles via command: API (
uvicorn), worker-jobs, worker-sync (matches SPEC §6.3WORKER_ROLE). One image, three commands. - Label images with the version and git SHA (
org.opencontainers.image.version,.revision). - Registry path:
{GITEA_HOST}/{OWNER}/webmail/backendand.../web. ReadGITEA_HOST/OWNERfrom CI variables, never hardcode.
3. CI pipeline (.gitea/workflows/ci.yml)
Trigger: push to any branch, and pull requests. Steps:
- Spin up service containers (postgres:16, redis:7, meilisearch:v1.8, greenmail) — reuse
docker/docker-compose.ci.ymlfrom DEV_ENVIRONMENT. - Backend gates:
ruff check,ruff format --check,mypy --strict app,pytest -q(unit + integration, coverage floors per SPEC §13). - Frontend gates:
tsc --noEmit,eslint,vitest run. - Build both Docker images (validates the Dockerfiles) — but on plain branch pushes, do not push to the registry (build-only smoke).
- Fail the whole workflow if any gate fails.
This gives every commit a green/red signal without deploying anything.
4. Release pipeline (.gitea/workflows/release.yml)
Trigger: push of a tag matching v*. Steps, in order, stopping on any failure:
- Run the full CI gate set (§3) against the tagged commit. No shortcuts — a tag that fails tests must not deploy.
- Build backend +
webimages withAPP_VERSION={tag}and the git SHA. - Log in to the Gitea container registry (using a CI secret token) and push both images tagged
{tag}andlatest. - Deploy to production by connecting to the production host over SSH (key from a CI secret) and running
deploy/deploy.sh {tag}. See §5. - On deploy success, write
{tag}todeploy/VERSIONand update/api/versionexpectation. On deploy failure, the pipeline must surface the error loudly and NOT leave a half-applied state (deploy.sh handles atomicity; see §5).
Keep the runner OFF the production box (run it on the dev/build machine or a spare fleet node) so a build can't starve the mail server.
5. The deploy script (deploy/deploy.sh) — safety is the whole point
Runs on the production host. Takes one argument: the target version tag. It MUST do, in this exact order:
- Record current version (read running
/api/versionordeploy/VERSION) → save as the rollback target. - Snapshot the database BEFORE touching anything — trigger a
pgBackRestbackup (or a ZFS snapshot oftank/pgif pgBackRest isn't configured yet). Abort the deploy if the snapshot fails. This is non-negotiable: a bad migration must always be recoverable. - Pull the new images for
{tag}from the Gitea registry. Abort if pull fails (nothing has changed yet — safe). - Run database migrations as a one-shot container:
alembic upgrade head(this includes plugin migration branches per SPEC §11.1). Migrations MUST be backward-compatible / expand-then-contract so the brief mixed-version window is safe. Abort + alert if migration fails (DB snapshot from step 2 is the recovery path). - Roll services in dependency order: workers first (
worker-sync,worker-jobs), then the API, thenweb—docker compose -f docker-compose.prod.yml up -d --waitwith the new tag.--waitblocks on healthchecks. - Health-gate: poll
/api/healthzfor up to 60 s. If it doesn't go green, automatically invokerollback.shto the saved previous version and exit non-zero. Never leave production unhealthy. - On success: write
{tag}todeploy/VERSION, log the old→new transition, done.
The script is idempotent and logs every step with timestamps. It reads all config (registry host, compose file path, healthcheck URL, ret(ry counts) from deploy/deploy.env, not hardcoded.
Because SPEC §6.3 sync workers are lease-owned and stateless, rolling them mid-operation is safe: leases transfer to the new worker within ~90 s, no mail lost, no duplicate rows (idempotent (mailbox_id, uid) upserts). Note this in the script comments so no future change breaks the assumption.
6. Rollback (deploy/rollback.sh)
One argument (target version) or, with no argument, the version recorded as "previous" by the last deploy. Steps: pull the old images (should be cached), roll services back to that tag, health-gate. Migration note: because migrations are expand-then-contract and backward-compatible, rolling the code back does not require rolling the schema back in the common case — document this clearly, and for the rare breaking migration, instruct the operator to restore the pre-deploy DB snapshot from step 5.2 (give the exact pgBackRest/ZFS restore command in docs/deployment.md).
7. One-time manual setup (docs/gitea-setup.md) — write this for a non-expert
The operator must do these once in Gitea's web UI; document each with exact click paths and screenshots-in-words:
- Enable Gitea Actions for the repo (Settings → Actions).
- Register a runner: provide the exact commands to run the
act_runnercontainer on the build machine, register it against the Gitea instance with a registration token, and label it (self-hosted,linux). Include the docker-compose snippet for the runner. - Enable the Gitea container registry (it's built in) and create a package-registry access token; store it as an Actions secret
REGISTRY_TOKEN. - Add Actions secrets:
REGISTRY_TOKEN,PROD_SSH_KEY(private key for the deploy user on the production host),PROD_HOST,PROD_USER. List exactly where in the UI (Settings → Actions → Secrets). - On the production host: create a low-privilege
deployuser, add it to thedockergroup, install its public key, anddocker loginto the Gitea registry once (or bake it into deploy.sh via the token). Give copy-paste commands. - First deploy: how to cut the first release —
git tag v1.0.0 && git push origin v1.0.0— and what success looks like (/api/versionreturns it).
Keep this doc short, sequential, and copy-pasteable. Assume the reader will follow it literally.
8. Operator runbook (docs/deployment.md) — plain language
Cover, in a few short sections:
- How to release: "Tag and push. That's it." —
git tag v1.4.3 && git push origin v1.4.3, then watch the Actions tab. - How to see what's running in production:
curl https://{host}/api/version. - How to roll back:
ssh {prod} 'cd /opt/webmail && ./deploy/rollback.sh'. - If a deploy fails: it auto-rolls-back and production stays on the old version; where to read the logs; when to restore a DB snapshot (rare) with the exact command.
- What NOT to do: never edit files directly on the server, never use
latestin the compose file, never skip the tag.
9. Migration discipline (encode as a rule)
Add a short docs/migrations.md: all Alembic migrations must be expand-then-contract — add new columns/tables in one release, backfill, switch code, drop old columns only in a later release once no running code needs them. This is what makes zero-downtime rolls and easy rollback possible. Add a CI check or PR-checklist item reminding of this; never generate a destructive migration (DROP/rename) in the same release that stops using the column.
10. Acceptance criteria
- Pushing a normal commit runs CI (tests + image build) and does not deploy.
- Pushing a
v*tag whose tests pass builds images, pushes them to the Gitea registry, and deploys to production with no manual step beyond the tag push. - Pushing a
v*tag whose tests fail produces no image and no deploy. /api/versionon production returns the deployed tag after a release.- A deploy whose new version fails its healthcheck auto-rolls-back and leaves production healthy on the prior version (test with a deliberately broken image).
rollback.shreturns production to the prior version in one command.- A DB snapshot exists (pgBackRest or ZFS) timestamped immediately before every migration — verify one is created on a test deploy.
- Rolling sync workers during a deploy loses no mail and creates no duplicate message rows (ties to SPEC §6.3 acceptance tests).
docs/gitea-setup.mdanddocs/deployment.mdare followable by a non-DevOps operator: every manual step has an exact command or click path.
11. Build order
- Health/version endpoints (§1) + Dockerfiles (§2) +
docker-compose.prod.yml. ci.yml(get green tests + building images first).- Registry push +
release.yml. deploy.sh+rollback.sh+deploy.env(test against a staging target — the same compose on a different port on the dev box — before pointing at the real host).- The two docs (§7, §8) +
migrations.md. - Prove all §10 acceptance criteria, including the deliberately-broken-image auto-rollback test.
Keep it boring and legible. The operator should never have to think about the mechanics again after the one-time Gitea setup.