From 11f1909f680044b29ea611084d14ca6eb0d7ab2d Mon Sep 17 00:00:00 2001 From: Samir Boulahtit Date: Fri, 13 Feb 2026 22:42:13 +0100 Subject: [PATCH] feat(cd): add continuous deployment on push to master Deploy job SSHes to production after ruff/pytest/architecture pass, running scripts/deploy.sh (stash, pull, docker rebuild, migrate, health check). Co-Authored-By: Claude Opus 4.6 --- .gitea/workflows/ci.yml | 18 ++++ docs/deployment/gitea.md | 48 ++++++----- docs/deployment/hetzner-server-setup.md | 104 +++++++++++++++++++----- scripts/deploy.sh | 67 +++++++++++++++ 4 files changed, 195 insertions(+), 42 deletions(-) create mode 100755 scripts/deploy.sh diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 6901163a..60ce81b1 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -163,3 +163,21 @@ jobs: with: name: docs-site path: site/ + + # --------------------------------------------------------------------------- + # Deploy (master-only, after lint + tests + architecture pass) + # --------------------------------------------------------------------------- + deploy: + runs-on: ubuntu-latest + if: github.event_name == 'push' && github.ref == 'refs/heads/master' + needs: [ruff, pytest, architecture] + steps: + - name: Deploy to production + uses: appleboy/ssh-action@v1 + with: + host: ${{ secrets.DEPLOY_HOST }} + username: ${{ secrets.DEPLOY_USER }} + key: ${{ secrets.DEPLOY_SSH_KEY }} + port: 22 + command_timeout: 10m + script: cd ${{ secrets.DEPLOY_PATH }} && bash scripts/deploy.sh diff --git a/docs/deployment/gitea.md b/docs/deployment/gitea.md index 8615fa63..14ed67fd 100644 --- a/docs/deployment/gitea.md +++ b/docs/deployment/gitea.md @@ -205,10 +205,10 @@ Configure these in your Gitea repository under **Settings > Actions > Secrets**: | Secret | Description | Used by | |--------|-------------|---------| -| `SSH_PRIVATE_KEY` | Private key for deployment server | Deploy job (if added) | -| `SERVER_HOST` | Production server IP/hostname | Deploy job | -| `SERVER_USER` | SSH user on production server | Deploy job | -| `SERVER_PATH` | App directory on server | Deploy job | +| `DEPLOY_SSH_KEY` | Ed25519 private key for deployment | Deploy job | +| `DEPLOY_HOST` | Production server IP (e.g. `127.0.0.1`) | Deploy job | +| `DEPLOY_USER` | SSH user on production server (e.g. `samir`) | Deploy job | +| `DEPLOY_PATH` | App directory on server (e.g. `/home/samir/apps/orion`) | Deploy job | --- @@ -223,16 +223,17 @@ push/PR to master ├── architecture (architecture validation) ├── dependency-scanning (pip-audit, non-blocking) ├── audit (custom audit, non-blocking) -└── docs (mkdocs build, master-only, after lint+test pass) +├── docs (mkdocs build, master-only, after lint+test pass) +└── deploy (SSH deploy, master-only, after lint+test+arch pass) ``` -All jobs run in parallel except `docs`, which waits for `ruff`, `pytest`, and `architecture` to pass. +All jobs run in parallel except `docs` and `deploy`, which wait for `ruff`, `pytest`, and `architecture` to pass. The `deploy` job only runs on push (not PRs). --- -## 9. Adding a Deploy Job (Optional) +## 9. Deploy Job (Continuous Deployment) -To add automated deployment via SSH (similar to the GitLab deploy stage), add this job to `.gitea/workflows/ci.yml`: +The CI pipeline includes an automated deploy job that runs on every successful push to master. It SSHes to the production server and runs a version-controlled deploy script: ```yaml deploy: @@ -240,23 +241,28 @@ To add automated deployment via SSH (similar to the GitLab deploy stage), add th if: github.event_name == 'push' && github.ref == 'refs/heads/master' needs: [ruff, pytest, architecture] steps: - - uses: actions/checkout@v4 - - - name: Deploy via SSH + - name: Deploy to production uses: appleboy/ssh-action@v1 with: - host: ${{ secrets.SERVER_HOST }} - username: ${{ secrets.SERVER_USER }} - key: ${{ secrets.SSH_PRIVATE_KEY }} - script: | - cd ${{ secrets.SERVER_PATH }} - git pull origin master - source .venv/bin/activate - uv sync --frozen - python -m alembic upgrade head - sudo systemctl restart wizamart + host: ${{ secrets.DEPLOY_HOST }} + username: ${{ secrets.DEPLOY_USER }} + key: ${{ secrets.DEPLOY_SSH_KEY }} + port: 22 + command_timeout: 10m + script: cd ${{ secrets.DEPLOY_PATH }} && bash scripts/deploy.sh ``` +The `scripts/deploy.sh` script handles the full deploy lifecycle: + +1. Stash local changes (preserves `.env` and other server-side edits) +2. Pull latest code (`--ff-only`) +3. Pop stash to restore local changes +4. Rebuild and restart Docker containers (`docker compose --profile full up -d --build`) +5. Run database migrations (`alembic upgrade heads`) +6. Health check `http://localhost:8001/health` with retries + +See [Hetzner Server Setup — Step 16](hetzner-server-setup.md#step-16-continuous-deployment) for the full setup guide including SSH key generation and Gitea secrets configuration. + --- ## 10. Firewall Configuration diff --git a/docs/deployment/hetzner-server-setup.md b/docs/deployment/hetzner-server-setup.md index f1fbfb2c..e398d116 100644 --- a/docs/deployment/hetzner-server-setup.md +++ b/docs/deployment/hetzner-server-setup.md @@ -14,7 +14,7 @@ Complete step-by-step guide for deploying Wizamart on a Hetzner Cloud VPS. - **Setup date**: 2026-02-11 !!! success "Progress — 2026-02-12" - **Completed (Steps 1–15):** + **Completed (Steps 1–16):** - Non-root user `samir` with SSH key - Server hardened (UFW firewall, SSH root login disabled, fail2ban) @@ -45,10 +45,10 @@ Complete step-by-step guide for deploying Wizamart on a Hetzner Cloud VPS. - AAAA (IPv6) records added for all wizard.lu domains - mkdocs build clean (zero warnings) — all 32 orphan pages added to nav - Pre-commit documented in `docs/development/code-quality.md` + - **Step 16: Continuous deployment** — auto-deploy on push to master via `scripts/deploy.sh` + Gitea Actions **Next steps:** - - [ ] Step 16: Continuous deployment — auto-deploy on push to master - [ ] Step 17: Backups — verify Hetzner backup scope, add PostgreSQL pg_dump - [ ] Step 18: Monitoring & observability — Prometheus, Grafana, uptime checks, alerting @@ -670,7 +670,83 @@ sudo systemctl status gitea-runner Verify the runner shows as **Online** in Gitea: **Site Administration > Actions > Runners**. -## Step 16: Verify Full Deployment +## Step 16: Continuous Deployment + +Automate deployment on every successful push to master. The Gitea Actions runner and the app both run on the same server, so the deploy job SSHes from the CI Docker container to `127.0.0.1`. + +``` +push to master + ├── ruff ──────┐ + ├── pytest ────┤ + └── architecture ─┤ + └── deploy (SSH → scripts/deploy.sh) + ├── git stash / pull / pop + ├── docker compose up -d --build + ├── alembic upgrade heads + └── health check (retries) +``` + +### 16.1 Generate Deploy SSH Key (on server) + +```bash +ssh-keygen -t ed25519 -C "gitea-deploy@wizard.lu" -f ~/.ssh/deploy_ed25519 -N "" +cat ~/.ssh/deploy_ed25519.pub >> ~/.ssh/authorized_keys +``` + +### 16.2 Add Gitea Secrets + +In **Repository Settings > Actions > Secrets**, add: + +| Secret | Value | +|---|---| +| `DEPLOY_SSH_KEY` | Contents of `~/.ssh/deploy_ed25519` (private key) | +| `DEPLOY_HOST` | `127.0.0.1` | +| `DEPLOY_USER` | `samir` | +| `DEPLOY_PATH` | `/home/samir/apps/orion` | + +### 16.3 Deploy Script + +The deploy script lives at `scripts/deploy.sh` in the repository. It: + +1. Stashes local changes (preserves `.env`) +2. Pulls latest code (`--ff-only`) +3. Pops stash to restore local changes +4. Rebuilds and restarts Docker containers (`docker compose --profile full up -d --build`) +5. Runs database migrations (`alembic upgrade heads`) +6. Health checks `http://localhost:8001/health` with 12 retries (60s total) + +Exit codes: `0` success, `1` git pull failed, `2` docker compose failed, `3` migration failed, `4` health check failed. + +### 16.4 CI Workflow + +The deploy job in `.gitea/workflows/ci.yml` runs only on master push, after `ruff`, `pytest`, and `architecture` pass: + +```yaml +deploy: + runs-on: ubuntu-latest + if: github.event_name == 'push' && github.ref == 'refs/heads/master' + needs: [ruff, pytest, architecture] + steps: + - name: Deploy to production + uses: appleboy/ssh-action@v1 + with: + host: ${{ secrets.DEPLOY_HOST }} + username: ${{ secrets.DEPLOY_USER }} + key: ${{ secrets.DEPLOY_SSH_KEY }} + port: 22 + command_timeout: 10m + script: cd ${{ secrets.DEPLOY_PATH }} && bash scripts/deploy.sh +``` + +### 16.5 Manual Fallback + +If CI is down, deploy manually: + +```bash +cd ~/apps/orion && bash scripts/deploy.sh +``` + +### 16.6 Verify ```bash # All app containers running @@ -771,29 +847,15 @@ git stash pop ## Maintenance -### Deploy updates (pull & rebuild) +### Deploy updates -After pushing code to Gitea from local: +Deployments happen automatically when pushing to master (see [Step 16](#step-16-continuous-deployment)). For manual deploys: ```bash -cd ~/apps/orion && git pull && docker compose --profile full up -d --build +cd ~/apps/orion && bash scripts/deploy.sh ``` -If there are local changes on the server (e.g. `.env` edits), stash first: - -```bash -cd ~/apps/orion -git stash # Save local config changes -git pull -git stash pop # Re-apply local config -docker compose --profile full up -d --build -``` - -If the update includes database migrations: - -```bash -docker compose --profile full exec -e PYTHONPATH=/app api python -m alembic upgrade heads -``` +The script handles stashing local changes, pulling, rebuilding containers, running migrations, and health checks. ### View logs diff --git a/scripts/deploy.sh b/scripts/deploy.sh new file mode 100755 index 00000000..5d378aa0 --- /dev/null +++ b/scripts/deploy.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +# ============================================================================= +# Wizamart Production Deploy Script +# ============================================================================= +# Usage: cd ~/apps/orion && bash scripts/deploy.sh +# +# Called by Gitea Actions CD pipeline (appleboy/ssh-action) or manually. +# +# Exit codes: +# 0 — success +# 1 — git pull failed +# 2 — docker compose build/up failed +# 3 — alembic migration failed +# 4 — health check failed +# ============================================================================= + +set -euo pipefail + +COMPOSE="docker compose --profile full" +HEALTH_URL="http://localhost:8001/health" +HEALTH_RETRIES=12 +HEALTH_INTERVAL=5 + +log() { echo "[deploy] $(date '+%H:%M:%S') $*"; } + +# ── 1. Pull latest code (stash local changes like .env) ───────────────────── +log "Stashing local changes …" +git stash --include-untracked --quiet || true + +log "Pulling latest code …" +if ! git pull --ff-only; then + log "ERROR: git pull failed" + git stash pop --quiet 2>/dev/null || true + exit 1 +fi + +log "Restoring local changes …" +git stash pop --quiet 2>/dev/null || true + +# ── 2. Rebuild and restart containers ──────────────────────────────────────── +log "Rebuilding containers …" +if ! $COMPOSE up -d --build; then + log "ERROR: docker compose up failed" + exit 2 +fi + +# ── 3. Run database migrations ─────────────────────────────────────────────── +log "Running database migrations …" +if ! $COMPOSE exec -T -e PYTHONPATH=/app api python -m alembic upgrade heads; then + log "ERROR: alembic migration failed" + exit 3 +fi + +# ── 4. Health check with retries ───────────────────────────────────────────── +log "Waiting for health check ($HEALTH_URL) …" +for i in $(seq 1 "$HEALTH_RETRIES"); do + if curl -sf "$HEALTH_URL" > /dev/null 2>&1; then + log "Health check passed (attempt $i/$HEALTH_RETRIES)" + log "Deploy complete." + exit 0 + fi + log "Health check attempt $i/$HEALTH_RETRIES failed, retrying in ${HEALTH_INTERVAL}s …" + sleep "$HEALTH_INTERVAL" +done + +log "ERROR: health check failed after $HEALTH_RETRIES attempts" +exit 4