feat: add Woodpecker CI + Gitea deployment templates

- .woodpecker.yml: full pipeline template (install → lint-fix → lint → test → deploy)
- scripts/setup-project.sh: one-command VPS setup (user, dir, deploy, sudoers, systemd, nginx)
- scripts/deploy.sh: deploy script template (rsync + npm ci + systemctl + health check)
- scripts/ci-lint-fix.sh: ESLint auto-fix with [CI SKIP] commit-back
- docs/ci-cd.md: complete CI/CD documentation and troubleshooting
- .env.example: added WOODPECKER_TOKEN
- DOCS.md: added CI/CD section

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
olekhondera
2026-02-20 23:24:41 +02:00
parent f5009636a0
commit 425227c6b8
7 changed files with 557 additions and 3 deletions

View File

@@ -31,6 +31,10 @@ NODE_ENV=development
# --- Email --- # --- Email ---
# RESEND_API_KEY= # RESEND_API_KEY=
# --- CI/CD (Woodpecker) ---
# WOODPECKER_TOKEN=
# WOODPECKER_URL=https://ci.spektr.design
# --- File Storage --- # --- File Storage ---
# S3_BUCKET= # S3_BUCKET=
# S3_REGION= # S3_REGION=

View File

@@ -1,6 +1,69 @@
# Woodpecker CI pipeline template.
#
# Runs on the local backend — image: bash specifies the shell.
# Triggers on push to main branch.
#
# Flow: install → lint-fix (optional) → lint + test → deploy
#
# Secrets (add in Woodpecker UI → repo or global settings):
# gitea_token — Gitea access token (for lint-fix auto-commit back)
#
# Deploy setup (one-time, as root on VPS):
# 1. Create deploy script:
# cp scripts/deploy.sh /usr/local/bin/deploy-<project-name>
# chmod 755 /usr/local/bin/deploy-<project-name>
# chown root:root /usr/local/bin/deploy-<project-name>
#
# 2. Find Woodpecker agent user:
# ps -o user= -p $(pgrep woodpecker-agent)
#
# 3. Set up sudoers (replace AGENT_USER and script name):
# echo 'AGENT_USER ALL=(root) NOPASSWD: /usr/local/bin/deploy-<project-name> *' \
# > /etc/sudoers.d/woodpecker-deploy
# chmod 0440 /etc/sudoers.d/woodpecker-deploy
# visudo -cf /etc/sudoers.d/woodpecker-deploy
when:
- event: push
branch: main
steps: steps:
- name: install
image: bash
commands:
- npm ci
# Optional: auto-fix lint issues and commit back.
# Requires gitea_token secret. Remove this step if not needed.
# The [CI SKIP] in commit message prevents infinite loops.
#
# - name: lint-fix
# image: bash
# environment:
# GITEA_TOKEN:
# from_secret: gitea_token
# commands:
# - bash scripts/ci-lint-fix.sh
# depends_on: [install]
- name: lint
image: bash
commands:
- npx eslint .
depends_on: [install]
- name: test - name: test
image: bash image: bash
commands: commands:
- echo "Hello from Woodpecker CI!" - npm test
- echo "Build successful" depends_on: [install]
# Uncomment when deploy script is set up on VPS:
#
# - name: deploy
# image: bash
# environment:
# CI: "true"
# commands:
# - sudo /usr/local/bin/deploy-<project-name> "${CI_WORKSPACE:-.}"
# depends_on: [lint, test]

10
DOCS.md
View File

@@ -51,7 +51,11 @@ Technical index for developers and AI agents. Use this as the entry point to all
- `docs/backend/security.md` — authN/Z, secret handling, webhook verification, audit/event logging. - `docs/backend/security.md` — authN/Z, secret handling, webhook verification, audit/event logging.
- `docs/backend/payment-flow.md` — payment integration (provider-agnostic template; single source of truth for payment flows and webhooks). - `docs/backend/payment-flow.md` — payment integration (provider-agnostic template; single source of truth for payment flows and webhooks).
### 4) Examples (`/docs/examples`) ### 4) CI/CD & Deployment
- `docs/ci-cd.md` — Woodpecker CI + Gitea setup, pipeline flow, deploy script, sudoers config, troubleshooting.
### 5) Examples (`/docs/examples`)
- `docs/examples/RECOMMENDATIONS-example.md` — filled-in example of `RECOMMENDATIONS.md` for a compliance classifier (Archetype C). - `docs/examples/RECOMMENDATIONS-example.md` — filled-in example of `RECOMMENDATIONS.md` for a compliance classifier (Archetype C).
@@ -68,6 +72,10 @@ Technical index for developers and AI agents. Use this as the entry point to all
- `package.json` — project metadata and Node.js engine requirement. - `package.json` — project metadata and Node.js engine requirement.
- `.env.example` — environment variables template. - `.env.example` — environment variables template.
- `.editorconfig` — editor formatting standards (indentation, line endings). - `.editorconfig` — editor formatting standards (indentation, line endings).
- `.woodpecker.yml` — Woodpecker CI pipeline config (local backend).
- `scripts/setup-project.sh` — Automated VPS setup for new projects (user, dir, deploy, sudoers, systemd, nginx).
- `scripts/deploy.sh` — VPS deploy script template (rsync + npm ci + systemctl).
- `scripts/ci-lint-fix.sh` — ESLint auto-fix with commit-back for CI.
## Agent Profiles (`/agents`) ## Agent Profiles (`/agents`)

114
docs/ci-cd.md Normal file
View File

@@ -0,0 +1,114 @@
# CI/CD — Woodpecker + Gitea
## Overview
Pipeline runs on Woodpecker CI with **local backend** (no Docker) on the same VPS.
Triggered by push to `main` branch via Gitea webhook.
## Pipeline Flow
```
push to main → install → lint-fix (optional) → lint + test → deploy
```
### Steps
| Step | What it does |
|------|-------------|
| **install** | `npm ci` — clean install dependencies |
| **lint-fix** | ESLint `--fix`, auto-commits back with `[CI SKIP]` |
| **lint** | ESLint check — fails pipeline on errors |
| **test** | `npm test` — fails pipeline on test failures |
| **deploy** | Runs deploy script via sudo on the VPS |
### lint-fix auto-commit
When ESLint can auto-fix issues (formatting, `var``const`, `==``===`, etc.),
the pipeline commits the fix back to Gitea with `[CI SKIP]` in the message.
Woodpecker natively skips pipelines for commits containing `[CI SKIP]`.
Requires `gitea_token` secret — a Gitea access token with repo write permission.
## Configuration
### `.woodpecker.yml`
Located at project root. Uses `image: bash` for local backend (specifies shell, not Docker image).
### Secrets (Woodpecker UI)
| Secret | Where | Purpose |
|--------|-------|---------|
| `gitea_token` | Global or repo | Gitea access token for lint-fix auto-commit |
Add secrets in Woodpecker UI → Settings → Secrets (repo level) or global level.
## Deploy Setup (one-time on VPS)
### Automated setup (recommended)
```bash
# On VPS as root:
bash setup-project.sh <project-name> <domain> [port]
# Example:
bash setup-project.sh my-app app.spektr.design 3000
```
This creates: service user, `/opt/<project>` directory, deploy script, sudoers rule, systemd service, nginx config.
After running, create `.env` and push code to Gitea.
### Manual setup
If you need more control, see `scripts/setup-project.sh` for individual steps:
1. Create service user (`useradd -r -s /sbin/nologin`)
2. Create `/opt/<project>` directory
3. Install deploy script to `/usr/local/bin/deploy-<project>`
4. Add sudoers rule for Woodpecker agent user
5. Create systemd service
6. Create nginx config
### Deploy script details
`scripts/deploy.sh` does:
1. rsync files (excludes node_modules, .env, .git)
2. `npm ci --omit=dev` as service user (with `HOME=` set to avoid npm cache errors)
3. `systemctl restart` + health check
The script runs as root via sudo. Only this specific script is allowed in sudoers.
## Troubleshooting
| Problem | Cause | Fix |
|---------|-------|-----|
| `secret "X" not found` | Secret not added in Woodpecker | Add in UI → Settings → Secrets |
| `Invalid or missing image` | Missing `image:` field | Use `image: bash` for local backend |
| `Permission denied` on deploy | Sudoers not configured | Follow deploy setup above |
| `command not found` for deploy script | Script not at `/usr/local/bin/` | Copy and chmod 755 |
| npm `EACCES mkdir /home/user` | Service user has no home dir | Set `HOME=/opt/<project>` in deploy script |
| Pipeline not triggered | Webhook issue or `[CI SKIP]` in message | Check Gitea webhook settings |
## Gitea Access Token
Create in Gitea: Settings → Applications → Generate New Token.
Permissions needed: `repo` (read/write).
## Woodpecker API
Check pipeline status programmatically:
```bash
# List recent pipelines
curl -H "Authorization: Bearer $TOKEN" \
https://ci.spektr.design/api/repos/<REPO_ID>/pipelines?page=1&per_page=5
# Get specific pipeline
curl -H "Authorization: Bearer $TOKEN" \
https://ci.spektr.design/api/repos/<REPO_ID>/pipelines/<NUMBER>
```
Store token in `.env.local` (gitignored):
```
WOODPECKER_TOKEN=<your-token>
```

39
scripts/ci-lint-fix.sh Executable file
View File

@@ -0,0 +1,39 @@
#!/usr/bin/env bash
set -euo pipefail
# ci-lint-fix.sh — Run ESLint --fix and commit changes back to Gitea.
#
# Used by Woodpecker CI. If ESLint auto-fixes anything, commits with
# [CI SKIP] to prevent an infinite pipeline loop.
#
# Requires GITEA_TOKEN secret in Woodpecker.
#
# Customize LINT_TARGETS and REPO_URL for your project.
LINT_TARGETS="."
GITEA_HOST="${GITEA_HOST:-git.spektr.design}"
GITEA_ORG="${GITEA_ORG:-gitea}"
REPO_NAME="${CI_REPO_NAME:-$(basename "$(git rev-parse --show-toplevel)")}"
echo "--- Running ESLint --fix"
# shellcheck disable=SC2086
npx eslint --fix ${LINT_TARGETS} || true
if [ -z "$(git diff)" ]; then
echo "--- No auto-fixable issues found"
exit 0
fi
echo "--- ESLint auto-fixed issues, committing back..."
git config user.name "Woodpecker CI"
git config user.email "ci@${GITEA_HOST}"
REPO_URL="https://gitea:${GITEA_TOKEN}@${GITEA_HOST}/${GITEA_ORG}/${REPO_NAME}.git"
git remote set-url origin "${REPO_URL}"
git add -A
git commit -m "fix: auto-fix eslint issues [CI SKIP]"
git push origin HEAD:main
echo "--- Auto-fix committed and pushed"

61
scripts/deploy.sh Executable file
View File

@@ -0,0 +1,61 @@
#!/usr/bin/env bash
set -euo pipefail
# deploy.sh — Deploy project from CI workspace to target directory.
#
# Works for both manual and Woodpecker CI deployment.
#
# Usage:
# Manual: sudo bash scripts/deploy.sh
# CI: sudo /usr/local/bin/deploy-<project-name> /path/to/workspace
#
# CI setup (one-time, as root on VPS):
# cp scripts/deploy.sh /usr/local/bin/deploy-<project-name>
# chmod 755 /usr/local/bin/deploy-<project-name>
# chown root:root /usr/local/bin/deploy-<project-name>
# --- Configure these for your project ---
INSTALL_DIR="/opt/<project-name>"
SERVICE_USER="<project-user>"
SERVICE_NAME="<project-name>"
HEALTH_URL="http://localhost:3000/api/health"
# -----------------------------------------
SOURCE_DIR="${1:-.}"
echo "=== Deploy ==="
if [[ $EUID -ne 0 ]]; then
echo "Error: must be root (sudo)"; exit 1
fi
if [[ ! -d "${INSTALL_DIR}" ]]; then
echo "Error: ${INSTALL_DIR} not found"; exit 1
fi
echo "[1/3] Syncing files..."
rsync -a --delete \
--exclude='node_modules' \
--exclude='.env' \
--exclude='.git' \
--exclude='.idea' \
"${SOURCE_DIR}/" "${INSTALL_DIR}/"
chown -R "${SERVICE_USER}:${SERVICE_USER}" "${INSTALL_DIR}"
echo "[2/3] Installing dependencies..."
sudo -u "${SERVICE_USER}" HOME="${INSTALL_DIR}" bash -c "cd ${INSTALL_DIR} && npm ci --omit=dev"
echo "[3/3] Restarting service..."
systemctl daemon-reload
systemctl restart "${SERVICE_NAME}"
sleep 2
if systemctl is-active --quiet "${SERVICE_NAME}"; then
echo "Service running"
else
echo "Error: service failed to start"; exit 1
fi
curl -sf "${HEALTH_URL}" || { echo "FAIL: health check"; exit 1; }
echo "=== Deploy complete ==="

265
scripts/setup-project.sh Executable file
View File

@@ -0,0 +1,265 @@
#!/usr/bin/env bash
set -euo pipefail
# setup-project.sh — One-time VPS setup for a new project.
#
# Run as root on the VPS:
# bash setup-project.sh <project-name> <domain> [port]
#
# Example:
# bash setup-project.sh my-app app.spektr.design 3000
#
# What it does:
# 1. Creates service user (no login shell, no home dir)
# 2. Creates /opt/<project> directory
# 3. Installs deploy script to /usr/local/bin/
# 4. Adds sudoers rule for Woodpecker agent
# 5. Creates systemd service
# 6. Creates nginx config (location blocks for Webuzo)
#
# After running, you still need to:
# - Create .env file at /opt/<project>/.env
# - Set up SSL (certbot or Webuzo panel)
# - Push code to Gitea to trigger first deploy
# ---------------------------------------------------------------------------
# Args
# ---------------------------------------------------------------------------
PROJECT="${1:?Usage: setup-project.sh <project-name> <domain> [port]}"
DOMAIN="${2:?Usage: setup-project.sh <project-name> <domain> [port]}"
PORT="${3:-3000}"
INSTALL_DIR="/opt/${PROJECT}"
SERVICE_USER="${PROJECT}"
DEPLOY_SCRIPT="/usr/local/bin/deploy-${PROJECT}"
# Detect Woodpecker agent user
AGENT_USER=$(ps -o user= -p "$(pgrep woodpecker-agent)" 2>/dev/null || echo "")
if [[ -z "${AGENT_USER}" ]]; then
echo "Warning: Woodpecker agent not running. Defaulting to 'git'."
AGENT_USER="git"
fi
echo "=== Setting up project: ${PROJECT} ==="
echo " Domain: ${DOMAIN}"
echo " Port: ${PORT}"
echo " Install dir: ${INSTALL_DIR}"
echo " User: ${SERVICE_USER}"
echo " Agent user: ${AGENT_USER}"
echo ""
# ---------------------------------------------------------------------------
# Checks
# ---------------------------------------------------------------------------
if [[ $EUID -ne 0 ]]; then
echo "Error: run as root (sudo)"; exit 1
fi
if [[ -d "${INSTALL_DIR}" ]]; then
echo "Warning: ${INSTALL_DIR} already exists. Continuing..."
fi
# ---------------------------------------------------------------------------
# 1. Service user
# ---------------------------------------------------------------------------
echo "[1/6] Creating service user..."
if id "${SERVICE_USER}" &>/dev/null; then
echo " User ${SERVICE_USER} already exists"
else
useradd -r -s /sbin/nologin "${SERVICE_USER}"
echo " Created user ${SERVICE_USER}"
fi
# ---------------------------------------------------------------------------
# 2. Project directory
# ---------------------------------------------------------------------------
echo "[2/6] Creating project directory..."
mkdir -p "${INSTALL_DIR}"
chown "${SERVICE_USER}:${SERVICE_USER}" "${INSTALL_DIR}"
echo " ${INSTALL_DIR} ready"
# ---------------------------------------------------------------------------
# 3. Deploy script
# ---------------------------------------------------------------------------
echo "[3/6] Installing deploy script..."
cat > "${DEPLOY_SCRIPT}" << DEPLOY
#!/usr/bin/env bash
set -euo pipefail
INSTALL_DIR="${INSTALL_DIR}"
SERVICE_USER="${SERVICE_USER}"
SERVICE_NAME="${PROJECT}"
HEALTH_URL="http://localhost:${PORT}/api/health"
SOURCE_DIR="\${1:-.}"
echo "=== Deploy ${PROJECT} ==="
if [[ \$EUID -ne 0 ]]; then
echo "Error: must be root"; exit 1
fi
if [[ ! -d "\${INSTALL_DIR}" ]]; then
echo "Error: \${INSTALL_DIR} not found"; exit 1
fi
echo "[1/3] Syncing files..."
rsync -a --delete --exclude='node_modules' --exclude='.env' --exclude='.git' --exclude='.idea' "\${SOURCE_DIR}/" "\${INSTALL_DIR}/"
chown -R "\${SERVICE_USER}:\${SERVICE_USER}" "\${INSTALL_DIR}"
echo "[2/3] Installing dependencies..."
sudo -u "\${SERVICE_USER}" HOME="\${INSTALL_DIR}" bash -c "cd \${INSTALL_DIR} && npm ci --omit=dev"
echo "[3/3] Restarting service..."
systemctl daemon-reload
systemctl restart "\${SERVICE_NAME}"
sleep 2
if systemctl is-active --quiet "\${SERVICE_NAME}"; then
echo "Service running"
else
echo "Error: service failed"; exit 1
fi
curl -sf "\${HEALTH_URL}" || { echo "FAIL: health check"; exit 1; }
echo "=== Deploy complete ==="
DEPLOY
chmod 755 "${DEPLOY_SCRIPT}"
# Strip trailing whitespace (heredoc can add it)
sed -i 's/[[:space:]]*$//' "${DEPLOY_SCRIPT}"
echo " ${DEPLOY_SCRIPT} installed"
# ---------------------------------------------------------------------------
# 4. Sudoers
# ---------------------------------------------------------------------------
echo "[4/6] Configuring sudoers..."
SUDOERS_FILE="/etc/sudoers.d/woodpecker-${PROJECT}"
echo "${AGENT_USER} ALL=(root) NOPASSWD: ${DEPLOY_SCRIPT} *" > "${SUDOERS_FILE}"
chmod 0440 "${SUDOERS_FILE}"
if visudo -cf "${SUDOERS_FILE}" &>/dev/null; then
echo " ${SUDOERS_FILE} — parsed OK"
else
echo " ERROR: sudoers syntax check failed!"
rm -f "${SUDOERS_FILE}"
exit 1
fi
# ---------------------------------------------------------------------------
# 5. Systemd service
# ---------------------------------------------------------------------------
echo "[5/6] Creating systemd service..."
cat > "/etc/systemd/system/${PROJECT}.service" << SERVICE
[Unit]
Description=${PROJECT}
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=${SERVICE_USER}
Group=${SERVICE_USER}
WorkingDirectory=${INSTALL_DIR}
ExecStart=/usr/bin/node ${INSTALL_DIR}/src/index.js
Restart=on-failure
RestartSec=5
StartLimitBurst=5
StartLimitIntervalSec=60
EnvironmentFile=-${INSTALL_DIR}/.env
NoNewPrivileges=true
PrivateTmp=true
ProtectHome=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
LimitNOFILE=65536
MemoryMax=256M
StandardOutput=journal
StandardError=journal
SyslogIdentifier=${PROJECT}
[Install]
WantedBy=multi-user.target
SERVICE
systemctl daemon-reload
echo " ${PROJECT}.service created"
# ---------------------------------------------------------------------------
# 6. Nginx config
# ---------------------------------------------------------------------------
echo "[6/6] Creating nginx config..."
NGINX_DIR="/var/webuzo-data/nginx/custom/domains"
if [[ -d "${NGINX_DIR}" ]]; then
NGINX_CONF="${NGINX_DIR}/${DOMAIN}.conf"
else
NGINX_DIR="/etc/nginx/conf.d"
NGINX_CONF="${NGINX_DIR}/${PROJECT}.conf"
fi
cat > "${NGINX_CONF}" << 'NGINX'
# Static files
location / {
root __INSTALL_DIR__/public;
index index.html;
try_files $uri $uri/ /index.html;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "no-referrer" always;
}
# API proxy
location ^~ /api/ {
proxy_pass http://127.0.0.1:__PORT__;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 10s;
proxy_send_timeout 30s;
proxy_read_timeout 30s;
}
# WebSocket proxy (if needed)
location ^~ /ws/ {
proxy_pass http://127.0.0.1:__PORT__;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_pass_header Upgrade;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 10s;
proxy_send_timeout 86400s;
proxy_read_timeout 86400s;
proxy_buffering off;
}
NGINX
sed -i "s|__INSTALL_DIR__|${INSTALL_DIR}|g" "${NGINX_CONF}"
sed -i "s|__PORT__|${PORT}|g" "${NGINX_CONF}"
if nginx -t 2>/dev/null; then
systemctl reload nginx
echo " ${NGINX_CONF} — nginx reloaded"
else
echo " Warning: nginx config test failed. Check ${NGINX_CONF} manually."
fi
# ---------------------------------------------------------------------------
# Done
# ---------------------------------------------------------------------------
echo ""
echo "=== Setup complete ==="
echo ""
echo "Next steps:"
echo " 1. Create .env: nano ${INSTALL_DIR}/.env"
echo " 2. Push code to Gitea — Woodpecker will deploy automatically"
echo " 3. Check: curl -s http://localhost:${PORT}/api/health"
echo " 4. Uncomment deploy step in .woodpecker.yml:"
echo " sudo /usr/local/bin/deploy-${PROJECT} \"\${CI_WORKSPACE:-.}\""