From 425227c6b86139802ea55f1b62a8c6c6cb645a21 Mon Sep 17 00:00:00 2001 From: olekhondera Date: Fri, 20 Feb 2026 23:24:41 +0200 Subject: [PATCH] feat: add Woodpecker CI + Gitea deployment templates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - .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 --- .env.example | 4 + .woodpecker.yml | 67 +++++++++- DOCS.md | 10 +- docs/ci-cd.md | 114 +++++++++++++++++ scripts/ci-lint-fix.sh | 39 ++++++ scripts/deploy.sh | 61 +++++++++ scripts/setup-project.sh | 265 +++++++++++++++++++++++++++++++++++++++ 7 files changed, 557 insertions(+), 3 deletions(-) create mode 100644 docs/ci-cd.md create mode 100755 scripts/ci-lint-fix.sh create mode 100755 scripts/deploy.sh create mode 100755 scripts/setup-project.sh diff --git a/.env.example b/.env.example index 2cc6165..62b13d8 100644 --- a/.env.example +++ b/.env.example @@ -31,6 +31,10 @@ NODE_ENV=development # --- Email --- # RESEND_API_KEY= +# --- CI/CD (Woodpecker) --- +# WOODPECKER_TOKEN= +# WOODPECKER_URL=https://ci.spektr.design + # --- File Storage --- # S3_BUCKET= # S3_REGION= diff --git a/.woodpecker.yml b/.woodpecker.yml index 587922f..a053712 100644 --- a/.woodpecker.yml +++ b/.woodpecker.yml @@ -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- +# chmod 755 /usr/local/bin/deploy- +# chown root:root /usr/local/bin/deploy- +# +# 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- *' \ +# > /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: + - 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 image: bash commands: - - echo "Hello from Woodpecker CI!" - - echo "Build successful" + - npm test + 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- "${CI_WORKSPACE:-.}" + # depends_on: [lint, test] diff --git a/DOCS.md b/DOCS.md index 8d41d7b..2468e9f 100644 --- a/DOCS.md +++ b/DOCS.md @@ -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/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). @@ -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. - `.env.example` — environment variables template. - `.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`) diff --git a/docs/ci-cd.md b/docs/ci-cd.md new file mode 100644 index 0000000..8459b5c --- /dev/null +++ b/docs/ci-cd.md @@ -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 [port] + +# Example: +bash setup-project.sh my-app app.spektr.design 3000 +``` + +This creates: service user, `/opt/` 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/` directory +3. Install deploy script to `/usr/local/bin/deploy-` +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/` 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//pipelines?page=1&per_page=5 + +# Get specific pipeline +curl -H "Authorization: Bearer $TOKEN" \ + https://ci.spektr.design/api/repos//pipelines/ +``` + +Store token in `.env.local` (gitignored): +``` +WOODPECKER_TOKEN= +``` diff --git a/scripts/ci-lint-fix.sh b/scripts/ci-lint-fix.sh new file mode 100755 index 0000000..ac2b915 --- /dev/null +++ b/scripts/ci-lint-fix.sh @@ -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" diff --git a/scripts/deploy.sh b/scripts/deploy.sh new file mode 100755 index 0000000..08f9ea8 --- /dev/null +++ b/scripts/deploy.sh @@ -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- /path/to/workspace +# +# CI setup (one-time, as root on VPS): +# cp scripts/deploy.sh /usr/local/bin/deploy- +# chmod 755 /usr/local/bin/deploy- +# chown root:root /usr/local/bin/deploy- + +# --- Configure these for your project --- +INSTALL_DIR="/opt/" +SERVICE_USER="" +SERVICE_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 ===" diff --git a/scripts/setup-project.sh b/scripts/setup-project.sh new file mode 100755 index 0000000..4b25798 --- /dev/null +++ b/scripts/setup-project.sh @@ -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 [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/ 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//.env +# - Set up SSL (certbot or Webuzo panel) +# - Push code to Gitea to trigger first deploy + +# --------------------------------------------------------------------------- +# Args +# --------------------------------------------------------------------------- + +PROJECT="${1:?Usage: setup-project.sh [port]}" +DOMAIN="${2:?Usage: setup-project.sh [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:-.}\""