feat(cd): add continuous deployment on push to master
Some checks failed
CI / ruff (push) Successful in 8s
CI / pytest (push) Successful in 36m19s
CI / architecture (push) Successful in 11s
CI / dependency-scanning (push) Successful in 27s
CI / audit (push) Successful in 9s
CI / docs (push) Failing after 59s
CI / deploy (push) Failing after 3s

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 <noreply@anthropic.com>
This commit is contained in:
2026-02-13 22:42:13 +01:00
parent 9154eec871
commit 11f1909f68
4 changed files with 195 additions and 42 deletions

View File

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

View File

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

View File

@@ -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 115):**
**Completed (Steps 116):**
- 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

67
scripts/deploy.sh Executable file
View File

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