Add Gitea Actions workflows, CI config, and docs
Some checks failed
Docker / docker (push) Successful in 6s
Security / security (push) Successful in 6s
Deploy / deploy-local-runner (push) Has been cancelled
CI / ci (push) Successful in 1m42s
Deploy / deploy-ssh (push) Successful in 7s

This commit is contained in:
2026-02-28 20:40:14 +01:00
parent 3b48b39561
commit 8cadb2d216
35 changed files with 3216 additions and 0 deletions

267
.gitea/workflows/ci.yml Normal file
View File

@@ -0,0 +1,267 @@
# =============================================================================
# CI Workflow — Continuous Integration
# =============================================================================
# Triggers on push and pull_request.
#
# Detection logic:
# 1. Python: if requirements.txt exists → install deps, lint, test.
# 2. Node/JS: if package.json exists → npm ci, lint, test, build.
# 3. Neither detected → print a message and exit 0 (never fail).
#
# Controlled by .ci/config.env:
# ENABLE_CI — master switch (default: true)
# CI_STRICT — if true, lint/test failures fail the workflow
# if false, failures are warnings only
#
# See docs/CI.md for full details.
# =============================================================================
name: CI
on:
push:
pull_request:
jobs:
ci:
runs-on: ubuntu-latest
steps:
# -----------------------------------------------------------------------
# Step 1: Checkout the code
# -----------------------------------------------------------------------
- name: Checkout
uses: actions/checkout@v4
# -----------------------------------------------------------------------
# Step 2: Load configuration from .ci/config.env
# -----------------------------------------------------------------------
- name: Load config
id: config
run: |
# Source config.env to get ENABLE_CI, CI_STRICT, etc.
if [ -f .ci/config.env ]; then
# Export all non-comment, non-empty lines
set -a
source .ci/config.env
set +a
echo "Config loaded from .ci/config.env"
else
echo "WARNING: .ci/config.env not found, using defaults"
ENABLE_CI=true
CI_STRICT=true
fi
# Pass values to subsequent steps via environment file
echo "ENABLE_CI=${ENABLE_CI:-true}" >> "$GITHUB_ENV"
echo "CI_STRICT=${CI_STRICT:-true}" >> "$GITHUB_ENV"
# -----------------------------------------------------------------------
# Step 3: Check master switch
# -----------------------------------------------------------------------
- name: Check if CI is enabled
run: |
if [ "$ENABLE_CI" != "true" ]; then
echo "CI is disabled (ENABLE_CI=$ENABLE_CI). Exiting."
exit 0
fi
# -----------------------------------------------------------------------
# Step 4: Detect project type
# -----------------------------------------------------------------------
- name: Detect project type
id: detect
run: |
HAS_PYTHON=false
HAS_NODE=false
if [ -f requirements.txt ] || [ -f setup.py ] || [ -f pyproject.toml ]; then
HAS_PYTHON=true
echo "Detected: Python project"
fi
if [ -f package.json ]; then
HAS_NODE=true
echo "Detected: Node.js project"
fi
if [ "$HAS_PYTHON" = "false" ] && [ "$HAS_NODE" = "false" ]; then
echo "No Python or Node.js project detected. CI will skip gracefully."
fi
echo "HAS_PYTHON=${HAS_PYTHON}" >> "$GITHUB_ENV"
echo "HAS_NODE=${HAS_NODE}" >> "$GITHUB_ENV"
# -----------------------------------------------------------------------
# Step 5: Python — Setup & Install
# -----------------------------------------------------------------------
- name: Set up Python
if: env.HAS_PYTHON == 'true'
uses: actions/setup-python@v5
with:
python-version: "3.x"
- name: Install Python dependencies
if: env.HAS_PYTHON == 'true'
run: |
python -m pip install --upgrade pip
if [ -f requirements.txt ]; then
pip install -r requirements.txt
fi
# Install optional dev tools (won't fail if not needed)
pip install ruff black flake8 pytest 2>/dev/null || true
# -----------------------------------------------------------------------
# Step 6: Python — Lint
# Runs ruff, then black --check, then flake8. Each is optional:
# only runs if the tool is installed AND relevant config exists.
# -----------------------------------------------------------------------
- name: Python lint
if: env.HAS_PYTHON == 'true'
run: |
EXIT_CODE=0
# --- ruff ---
if command -v ruff >/dev/null 2>&1; then
echo ">>> ruff check ."
ruff check . || EXIT_CODE=$?
else
echo "SKIP: ruff not installed"
fi
# --- black ---
if command -v black >/dev/null 2>&1; then
echo ">>> black --check ."
black --check . || EXIT_CODE=$?
else
echo "SKIP: black not installed"
fi
# --- flake8 ---
if command -v flake8 >/dev/null 2>&1; then
echo ">>> flake8 ."
flake8 . || EXIT_CODE=$?
else
echo "SKIP: flake8 not installed"
fi
if [ "$EXIT_CODE" -ne 0 ]; then
if [ "$CI_STRICT" = "true" ]; then
echo "ERROR: Lint failed (CI_STRICT=true)"
exit 1
else
echo "WARNING: Lint issues found (CI_STRICT=false, continuing)"
fi
fi
# -----------------------------------------------------------------------
# Step 7: Python — Test
# Runs pytest if a tests/ directory or pytest config is detected.
# -----------------------------------------------------------------------
- name: Python test
if: env.HAS_PYTHON == 'true'
run: |
# Check if tests exist
HAS_TESTS=false
if [ -d tests ] || [ -d test ]; then
HAS_TESTS=true
fi
# Check for pytest config in pyproject.toml or pytest.ini
if [ -f pytest.ini ] || [ -f setup.cfg ]; then
HAS_TESTS=true
fi
if [ -f pyproject.toml ] && grep -q '\[tool\.pytest' pyproject.toml 2>/dev/null; then
HAS_TESTS=true
fi
if [ "$HAS_TESTS" = "true" ]; then
echo ">>> pytest"
if ! pytest; then
if [ "$CI_STRICT" = "true" ]; then
echo "ERROR: Tests failed (CI_STRICT=true)"
exit 1
else
echo "WARNING: Tests failed (CI_STRICT=false, continuing)"
fi
fi
else
echo "SKIP: No tests detected (no tests/ dir or pytest config)"
fi
# -----------------------------------------------------------------------
# Step 8: Node.js — Setup & Install
# -----------------------------------------------------------------------
- name: Set up Node.js
if: env.HAS_NODE == 'true'
uses: actions/setup-node@v4
with:
node-version: "lts/*"
- name: Install Node dependencies
if: env.HAS_NODE == 'true'
run: npm ci
# -----------------------------------------------------------------------
# Step 9: Node.js — Lint (only if "lint" script exists in package.json)
# -----------------------------------------------------------------------
- name: Node lint
if: env.HAS_NODE == 'true'
run: |
if grep -q '"lint"' package.json 2>/dev/null; then
echo ">>> npm run lint"
if ! npm run lint; then
if [ "$CI_STRICT" = "true" ]; then
echo "ERROR: Lint failed (CI_STRICT=true)"
exit 1
else
echo "WARNING: Lint issues found (CI_STRICT=false, continuing)"
fi
fi
else
echo "SKIP: no 'lint' script in package.json"
fi
# -----------------------------------------------------------------------
# Step 10: Node.js — Test (only if "test" script exists)
# -----------------------------------------------------------------------
- name: Node test
if: env.HAS_NODE == 'true'
run: |
if grep -q '"test"' package.json 2>/dev/null; then
echo ">>> npm test"
if ! npm test; then
if [ "$CI_STRICT" = "true" ]; then
echo "ERROR: Tests failed (CI_STRICT=true)"
exit 1
else
echo "WARNING: Tests failed (CI_STRICT=false, continuing)"
fi
fi
else
echo "SKIP: no 'test' script in package.json"
fi
# -----------------------------------------------------------------------
# Step 11: Node.js — Build (only if "build" script exists)
# -----------------------------------------------------------------------
- name: Node build
if: env.HAS_NODE == 'true'
run: |
if grep -q '"build"' package.json 2>/dev/null; then
echo ">>> npm run build"
npm run build
else
echo "SKIP: no 'build' script in package.json"
fi
# -----------------------------------------------------------------------
# Step 12: Summary
# -----------------------------------------------------------------------
- name: CI Summary
if: always()
run: |
echo "=============================="
echo " CI Complete"
echo " Python detected: $HAS_PYTHON"
echo " Node detected: $HAS_NODE"
echo " Strict mode: $CI_STRICT"
echo "=============================="

451
.gitea/workflows/deploy.yml Normal file
View File

@@ -0,0 +1,451 @@
# =============================================================================
# Deploy Workflow — Automated Deployment to VPS
# =============================================================================
#
# PURPOSE:
# Deploy your application to a VPS after a successful push to the default
# branch (main). Supports two deployment modes:
#
# (A) local-runner — The deploy job runs directly on a self-hosted act_runner
# installed ON the VPS. No SSH needed. The runner is selected by a label
# (DEPLOY_RUNNER_LABEL). This is the recommended mode.
#
# (B) ssh — The deploy job runs on any runner and SSHs into the VPS to
# execute commands remotely. Requires SSH secrets. Use as fallback when
# you can't install a runner on the target VPS.
#
# SAFE BY DEFAULT:
# ENABLE_DEPLOY=false in .ci/config.env. Deploy never runs unless you
# explicitly enable it. It also never runs on pull_request events.
#
# DEPLOY STRATEGIES:
# compose — docker compose pull && docker compose up -d
# systemd — systemctl restart <service>
# script — run a custom deploy script
#
# TRIGGERS:
# - push to DEFAULT_BRANCH (main) → deploy if enabled
# - tag v* (only if DEPLOY_ON_TAG=true) → deploy if enabled
# - pull_request → NEVER (not in trigger list)
#
# REQUIRED SECRETS (ssh mode only):
# DEPLOY_SSH_KEY — private SSH key (ed25519 or RSA)
# DEPLOY_HOST — VPS hostname or IP
# DEPLOY_USER — SSH username on VPS
# DEPLOY_KNOWN_HOSTS — (optional) known_hosts entry for the VPS
#
# For local-runner mode: NO secrets needed. The runner already has local
# access. Just ensure the runner is registered with the correct label.
#
# See docs/DEPLOY.md for full setup instructions.
# =============================================================================
name: Deploy
# ---------------------------------------------------------------------------
# TRIGGERS
# ---------------------------------------------------------------------------
# Only push events — never pull_request.
# Branch filter is further enforced in the "branch guard" step below,
# because config.env may specify a different DEFAULT_BRANCH.
on:
push:
branches:
- main
tags:
- "v*"
# =============================================================================
# JOB: deploy-local-runner
# =============================================================================
# Runs directly on the VPS via a labeled self-hosted act_runner.
# This job is skipped if DEPLOY_MODE != local-runner.
# ---------------------------------------------------------------------------
jobs:
deploy-local-runner:
# -------------------------------------------------------------------------
# Runner selection:
# The 'runs-on' value is read from config at workflow parse time, but
# Gitea Actions does not support dynamic runs-on from env vars.
# We use the default label here; if you changed DEPLOY_RUNNER_LABEL in
# config.env, you MUST also update this runs-on value to match.
#
# HOW TO CHANGE: replace 'deploy-ovh' below with your label.
# -------------------------------------------------------------------------
runs-on: deploy-ovh
steps:
# -----------------------------------------------------------------------
# Step 1: Load configuration
# -----------------------------------------------------------------------
- name: Checkout (for config only)
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Load config
id: config
run: |
# Source config.env
if [ -f .ci/config.env ]; then
set -a
source .ci/config.env
set +a
echo "Config loaded."
else
echo "WARNING: .ci/config.env not found, using defaults."
fi
# Export all deploy-related vars with safe defaults
echo "ENABLE_DEPLOY=${ENABLE_DEPLOY:-false}" >> "$GITHUB_ENV"
echo "DEPLOY_MODE=${DEPLOY_MODE:-local-runner}" >> "$GITHUB_ENV"
echo "DEPLOY_WORKDIR=${DEPLOY_WORKDIR:-/opt/app}" >> "$GITHUB_ENV"
echo "DEPLOY_STRATEGY=${DEPLOY_STRATEGY:-compose}" >> "$GITHUB_ENV"
echo "DEPLOY_COMPOSE_FILE=${DEPLOY_COMPOSE_FILE:-docker-compose.yml}" >> "$GITHUB_ENV"
echo "DEPLOY_SYSTEMD_SERVICE=${DEPLOY_SYSTEMD_SERVICE:-}" >> "$GITHUB_ENV"
echo "DEPLOY_SCRIPT=${DEPLOY_SCRIPT:-scripts/deploy.sh}" >> "$GITHUB_ENV"
echo "DEPLOY_ON_TAG=${DEPLOY_ON_TAG:-false}" >> "$GITHUB_ENV"
echo "DEFAULT_BRANCH=${DEFAULT_BRANCH:-main}" >> "$GITHUB_ENV"
# -----------------------------------------------------------------------
# Step 2: Gate checks — abort early if deploy should not run
# -----------------------------------------------------------------------
- name: Check if deploy is enabled
run: |
if [ "$ENABLE_DEPLOY" != "true" ]; then
echo "========================================="
echo " Deploy is DISABLED (ENABLE_DEPLOY=$ENABLE_DEPLOY)"
echo " To enable: set ENABLE_DEPLOY=true in .ci/config.env"
echo "========================================="
exit 0
fi
# Ensure this job is for the correct mode
if [ "$DEPLOY_MODE" != "local-runner" ]; then
echo "DEPLOY_MODE=$DEPLOY_MODE (not local-runner). This job is a no-op."
echo "The ssh job will handle deployment instead."
exit 0
fi
echo "DEPLOY_ACTIVE=true" >> "$GITHUB_ENV"
# -----------------------------------------------------------------------
# Step 3: Branch guard
# Only deploy from DEFAULT_BRANCH. For tags, check DEPLOY_ON_TAG.
# This is a SAFETY net — even though 'on.push.branches' is set above,
# DEFAULT_BRANCH might differ from 'main'.
# -----------------------------------------------------------------------
- name: Branch guard
if: env.DEPLOY_ACTIVE == 'true'
run: |
REF="${GITHUB_REF:-}"
# Tag push?
if echo "$REF" | grep -q '^refs/tags/v'; then
if [ "$DEPLOY_ON_TAG" != "true" ]; then
echo "Tag push detected but DEPLOY_ON_TAG=$DEPLOY_ON_TAG. Skipping."
echo "DEPLOY_ACTIVE=false" >> "$GITHUB_ENV"
exit 0
fi
echo "Deploying on tag: $REF"
exit 0
fi
# Branch push — verify it's DEFAULT_BRANCH
BRANCH="$(echo "$REF" | sed 's|refs/heads/||')"
if [ "$BRANCH" != "$DEFAULT_BRANCH" ]; then
echo "Branch '$BRANCH' is not DEFAULT_BRANCH '$DEFAULT_BRANCH'. Skipping."
echo "DEPLOY_ACTIVE=false" >> "$GITHUB_ENV"
exit 0
fi
echo "Deploying on branch: $BRANCH"
# -----------------------------------------------------------------------
# Step 4: Execute deploy strategy (LOCAL — runs on the VPS itself)
# -----------------------------------------------------------------------
- name: "Deploy: compose"
if: env.DEPLOY_ACTIVE == 'true' && env.DEPLOY_STRATEGY == 'compose'
run: |
echo ">>> Deploy strategy: compose"
echo ">>> Working directory: $DEPLOY_WORKDIR"
echo ">>> Compose file: $DEPLOY_COMPOSE_FILE"
cd "$DEPLOY_WORKDIR" || { echo "ERROR: Cannot cd to $DEPLOY_WORKDIR"; exit 1; }
echo ">>> docker compose -f $DEPLOY_COMPOSE_FILE pull"
docker compose -f "$DEPLOY_COMPOSE_FILE" pull
echo ">>> docker compose -f $DEPLOY_COMPOSE_FILE up -d"
docker compose -f "$DEPLOY_COMPOSE_FILE" up -d
echo "Deploy (compose) complete."
- name: "Deploy: systemd"
if: env.DEPLOY_ACTIVE == 'true' && env.DEPLOY_STRATEGY == 'systemd'
run: |
echo ">>> Deploy strategy: systemd"
if [ -z "$DEPLOY_SYSTEMD_SERVICE" ]; then
echo "ERROR: DEPLOY_SYSTEMD_SERVICE is not set."
echo "Set it in .ci/config.env for strategy=systemd."
exit 1
fi
echo ">>> systemctl restart $DEPLOY_SYSTEMD_SERVICE"
sudo systemctl restart "$DEPLOY_SYSTEMD_SERVICE"
echo ">>> systemctl status $DEPLOY_SYSTEMD_SERVICE"
sudo systemctl status "$DEPLOY_SYSTEMD_SERVICE" --no-pager
echo "Deploy (systemd) complete."
- name: "Deploy: script"
if: env.DEPLOY_ACTIVE == 'true' && env.DEPLOY_STRATEGY == 'script'
run: |
echo ">>> Deploy strategy: script"
echo ">>> Script: $DEPLOY_SCRIPT"
echo ">>> Workdir arg: $DEPLOY_WORKDIR"
if [ ! -f "$DEPLOY_SCRIPT" ]; then
echo "ERROR: Deploy script not found: $DEPLOY_SCRIPT"
exit 1
fi
chmod +x "$DEPLOY_SCRIPT"
"./$DEPLOY_SCRIPT" "$DEPLOY_WORKDIR"
echo "Deploy (script) complete."
# -----------------------------------------------------------------------
# Step 5: Summary
# -----------------------------------------------------------------------
- name: Deploy summary
if: always()
run: |
echo "=============================="
echo " Deploy (local-runner)"
echo " Enabled: ${ENABLE_DEPLOY:-false}"
echo " Mode: ${DEPLOY_MODE:-local-runner}"
echo " Strategy: ${DEPLOY_STRATEGY:-compose}"
echo " Workdir: ${DEPLOY_WORKDIR:-/opt/app}"
echo " Active: ${DEPLOY_ACTIVE:-false}"
echo "=============================="
# ===========================================================================
# JOB: deploy-ssh
# ===========================================================================
# Runs on a normal runner and SSHs into the VPS to deploy.
# This job is skipped if DEPLOY_MODE != ssh.
# ---------------------------------------------------------------------------
deploy-ssh:
runs-on: ubuntu-latest
steps:
# -----------------------------------------------------------------------
# Step 1: Load configuration
# -----------------------------------------------------------------------
- name: Checkout (for config + scripts)
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Load config
run: |
if [ -f .ci/config.env ]; then
set -a
source .ci/config.env
set +a
echo "Config loaded."
else
echo "WARNING: .ci/config.env not found, using defaults."
fi
echo "ENABLE_DEPLOY=${ENABLE_DEPLOY:-false}" >> "$GITHUB_ENV"
echo "DEPLOY_MODE=${DEPLOY_MODE:-local-runner}" >> "$GITHUB_ENV"
echo "DEPLOY_WORKDIR=${DEPLOY_WORKDIR:-/opt/app}" >> "$GITHUB_ENV"
echo "DEPLOY_STRATEGY=${DEPLOY_STRATEGY:-compose}" >> "$GITHUB_ENV"
echo "DEPLOY_COMPOSE_FILE=${DEPLOY_COMPOSE_FILE:-docker-compose.yml}" >> "$GITHUB_ENV"
echo "DEPLOY_SYSTEMD_SERVICE=${DEPLOY_SYSTEMD_SERVICE:-}" >> "$GITHUB_ENV"
echo "DEPLOY_SCRIPT=${DEPLOY_SCRIPT:-scripts/deploy.sh}" >> "$GITHUB_ENV"
echo "DEPLOY_ON_TAG=${DEPLOY_ON_TAG:-false}" >> "$GITHUB_ENV"
echo "DEFAULT_BRANCH=${DEFAULT_BRANCH:-main}" >> "$GITHUB_ENV"
# -----------------------------------------------------------------------
# Step 2: Gate checks
# -----------------------------------------------------------------------
- name: Check if deploy is enabled
run: |
if [ "$ENABLE_DEPLOY" != "true" ]; then
echo "========================================="
echo " Deploy is DISABLED (ENABLE_DEPLOY=$ENABLE_DEPLOY)"
echo " To enable: set ENABLE_DEPLOY=true in .ci/config.env"
echo "========================================="
exit 0
fi
if [ "$DEPLOY_MODE" != "ssh" ]; then
echo "DEPLOY_MODE=$DEPLOY_MODE (not ssh). This job is a no-op."
echo "The local-runner job will handle deployment instead."
exit 0
fi
echo "DEPLOY_ACTIVE=true" >> "$GITHUB_ENV"
# -----------------------------------------------------------------------
# Step 3: Branch guard (same logic as local-runner)
# -----------------------------------------------------------------------
- name: Branch guard
if: env.DEPLOY_ACTIVE == 'true'
run: |
REF="${GITHUB_REF:-}"
if echo "$REF" | grep -q '^refs/tags/v'; then
if [ "$DEPLOY_ON_TAG" != "true" ]; then
echo "Tag push detected but DEPLOY_ON_TAG=$DEPLOY_ON_TAG. Skipping."
echo "DEPLOY_ACTIVE=false" >> "$GITHUB_ENV"
exit 0
fi
echo "Deploying on tag: $REF"
exit 0
fi
BRANCH="$(echo "$REF" | sed 's|refs/heads/||')"
if [ "$BRANCH" != "$DEFAULT_BRANCH" ]; then
echo "Branch '$BRANCH' is not DEFAULT_BRANCH '$DEFAULT_BRANCH'. Skipping."
echo "DEPLOY_ACTIVE=false" >> "$GITHUB_ENV"
exit 0
fi
echo "Deploying on branch: $BRANCH"
# -----------------------------------------------------------------------
# Step 4: Set up SSH
#
# Secrets required:
# DEPLOY_SSH_KEY — private key (ed25519 recommended)
# DEPLOY_HOST — VPS IP or hostname
# DEPLOY_USER — SSH username
# DEPLOY_KNOWN_HOSTS — (optional) output of ssh-keyscan for the host
#
# If DEPLOY_KNOWN_HOSTS is not set, StrictHostKeyChecking is disabled.
# This is less secure but avoids first-connect failures in CI.
# For production, always set DEPLOY_KNOWN_HOSTS.
# -----------------------------------------------------------------------
- name: Set up SSH
if: env.DEPLOY_ACTIVE == 'true'
run: |
mkdir -p ~/.ssh
chmod 700 ~/.ssh
# Write private key (never echo it)
echo "${{ secrets.DEPLOY_SSH_KEY }}" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
# Known hosts — if provided, use it; otherwise disable strict checking
KNOWN_HOSTS="${{ secrets.DEPLOY_KNOWN_HOSTS }}"
if [ -n "$KNOWN_HOSTS" ]; then
echo "$KNOWN_HOSTS" > ~/.ssh/known_hosts
chmod 644 ~/.ssh/known_hosts
echo "known_hosts configured from secret."
else
echo "WARNING: DEPLOY_KNOWN_HOSTS not set. Disabling StrictHostKeyChecking."
echo "For production, set DEPLOY_KNOWN_HOSTS (run: ssh-keyscan your-host)"
{
echo "Host *"
echo " StrictHostKeyChecking no"
echo " UserKnownHostsFile /dev/null"
} > ~/.ssh/config
chmod 600 ~/.ssh/config
fi
# Build SSH command for reuse
SSH_CMD="ssh -i ~/.ssh/deploy_key ${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}"
echo "SSH_CMD=${SSH_CMD}" >> "$GITHUB_ENV"
# Verify connectivity
echo "Testing SSH connection..."
$SSH_CMD "echo 'SSH connection successful.'" || {
echo "ERROR: SSH connection failed."
exit 1
}
# -----------------------------------------------------------------------
# Step 5: Execute deploy strategy (REMOTE — via SSH)
# -----------------------------------------------------------------------
- name: "Deploy via SSH: compose"
if: env.DEPLOY_ACTIVE == 'true' && env.DEPLOY_STRATEGY == 'compose'
run: |
echo ">>> Deploy strategy: compose (via SSH)"
$SSH_CMD << DEPLOY_EOF
set -e
echo ">>> cd $DEPLOY_WORKDIR"
cd "$DEPLOY_WORKDIR" || { echo "ERROR: Cannot cd to $DEPLOY_WORKDIR"; exit 1; }
echo ">>> docker compose -f $DEPLOY_COMPOSE_FILE pull"
docker compose -f "$DEPLOY_COMPOSE_FILE" pull
echo ">>> docker compose -f $DEPLOY_COMPOSE_FILE up -d"
docker compose -f "$DEPLOY_COMPOSE_FILE" up -d
echo "Deploy (compose) complete."
DEPLOY_EOF
- name: "Deploy via SSH: systemd"
if: env.DEPLOY_ACTIVE == 'true' && env.DEPLOY_STRATEGY == 'systemd'
run: |
echo ">>> Deploy strategy: systemd (via SSH)"
if [ -z "$DEPLOY_SYSTEMD_SERVICE" ]; then
echo "ERROR: DEPLOY_SYSTEMD_SERVICE is not set."
exit 1
fi
$SSH_CMD << DEPLOY_EOF
set -e
echo ">>> sudo systemctl restart $DEPLOY_SYSTEMD_SERVICE"
sudo systemctl restart "$DEPLOY_SYSTEMD_SERVICE"
echo ">>> systemctl status $DEPLOY_SYSTEMD_SERVICE"
sudo systemctl status "$DEPLOY_SYSTEMD_SERVICE" --no-pager
echo "Deploy (systemd) complete."
DEPLOY_EOF
- name: "Deploy via SSH: script"
if: env.DEPLOY_ACTIVE == 'true' && env.DEPLOY_STRATEGY == 'script'
run: |
echo ">>> Deploy strategy: script (via SSH)"
# Copy the deploy script to the VPS
scp -i ~/.ssh/deploy_key \
"$DEPLOY_SCRIPT" \
"${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}:/tmp/_deploy_script.sh"
$SSH_CMD << DEPLOY_EOF
set -e
chmod +x /tmp/_deploy_script.sh
/tmp/_deploy_script.sh "$DEPLOY_WORKDIR"
rm -f /tmp/_deploy_script.sh
echo "Deploy (script) complete."
DEPLOY_EOF
# -----------------------------------------------------------------------
# Step 6: Clean up SSH key (always runs)
# -----------------------------------------------------------------------
- name: Clean up SSH
if: always()
run: |
rm -rf ~/.ssh/deploy_key ~/.ssh/config 2>/dev/null || true
# -----------------------------------------------------------------------
# Step 7: Summary
# -----------------------------------------------------------------------
- name: Deploy summary
if: always()
run: |
echo "=============================="
echo " Deploy (ssh)"
echo " Enabled: ${ENABLE_DEPLOY:-false}"
echo " Mode: ${DEPLOY_MODE:-ssh}"
echo " Strategy: ${DEPLOY_STRATEGY:-compose}"
echo " Workdir: ${DEPLOY_WORKDIR:-/opt/app}"
echo " Active: ${DEPLOY_ACTIVE:-false}"
echo "=============================="

284
.gitea/workflows/docker.yml Normal file
View File

@@ -0,0 +1,284 @@
# =============================================================================
# Docker Workflow — Build & Push to Gitea Container Registry
# =============================================================================
#
# PURPOSE:
# Build Docker images generically; optionally push to the Gitea Container
# Registry at git.hiddenden.cafe.
#
# TRIGGERS:
# - pull_request → build only (never push)
# - push to main → build; push only if ENABLE_DOCKER=true AND DOCKER_PUSH=true
# - tag v* → build; push only if DOCKER_PUSH=true AND DOCKER_PUSH_ON_TAG=true
#
# DETECTION:
# - Dockerfile exists → docker build
# - docker-compose.yml exists → docker compose build
# - Neither → exit 0 gracefully
#
# NAMING (Gitea convention):
# Image ref: ${REGISTRY_HOST}/${IMAGE_OWNER}/${IMAGE_NAME}:${TAG}
# Example: git.hiddenden.cafe/myorg/myrepo:1.2.3
#
# AUTHENTICATION:
# Uses PAT-based secrets (recommended for Gitea Actions):
# - REGISTRY_USERNAME — your Gitea username or bot account
# - REGISTRY_TOKEN — a Personal Access Token with package:write scope
# Set these in: Repository Settings → Secrets (or Organization Secrets).
# NEVER echo secrets in logs.
#
# CONFIG:
# All settings loaded from .ci/config.env. See docs/DOCKER.md.
#
# =============================================================================
name: Docker
on:
push:
branches:
- main
tags:
- "v*"
pull_request:
jobs:
docker:
runs-on: ubuntu-latest
steps:
# -----------------------------------------------------------------------
# Step 1: Checkout
# -----------------------------------------------------------------------
- name: Checkout
uses: actions/checkout@v4
# -----------------------------------------------------------------------
# Step 2: Load configuration
# -----------------------------------------------------------------------
- name: Load config
run: |
if [ -f .ci/config.env ]; then
set -a
source .ci/config.env
set +a
echo "Config loaded from .ci/config.env"
else
echo "WARNING: .ci/config.env not found, using defaults"
fi
# Export with defaults
echo "ENABLE_DOCKER=${ENABLE_DOCKER:-true}" >> "$GITHUB_ENV"
echo "DOCKER_PUSH=${DOCKER_PUSH:-false}" >> "$GITHUB_ENV"
echo "DOCKER_PUSH_ON_BRANCH=${DOCKER_PUSH_ON_BRANCH:-true}" >> "$GITHUB_ENV"
echo "DOCKER_PUSH_ON_TAG=${DOCKER_PUSH_ON_TAG:-true}" >> "$GITHUB_ENV"
echo "REGISTRY_HOST=${REGISTRY_HOST:-git.hiddenden.cafe}" >> "$GITHUB_ENV"
echo "IMAGE_OWNER_CFG=${IMAGE_OWNER:-auto}" >> "$GITHUB_ENV"
echo "IMAGE_NAME_CFG=${IMAGE_NAME:-auto}" >> "$GITHUB_ENV"
echo "DOCKER_TAG_STRATEGY=${DOCKER_TAG_STRATEGY:-semver+latest}" >> "$GITHUB_ENV"
echo "DEFAULT_BRANCH=${DEFAULT_BRANCH:-main}" >> "$GITHUB_ENV"
# -----------------------------------------------------------------------
# Step 3: Check if Docker is enabled
# -----------------------------------------------------------------------
- name: Check if Docker is enabled
run: |
if [ "$ENABLE_DOCKER" != "true" ]; then
echo "Docker is disabled (ENABLE_DOCKER=$ENABLE_DOCKER). Exiting."
# We still exit 0 — graceful skip.
echo "SKIP_DOCKER=true" >> "$GITHUB_ENV"
fi
# -----------------------------------------------------------------------
# Step 4: Detect Dockerfile or docker-compose.yml
# -----------------------------------------------------------------------
- name: Detect Docker files
if: env.SKIP_DOCKER != 'true'
run: |
if [ -f Dockerfile ]; then
echo "DOCKER_MODE=dockerfile" >> "$GITHUB_ENV"
echo "Detected: Dockerfile"
elif [ -f docker-compose.yml ] || [ -f docker-compose.yaml ]; then
echo "DOCKER_MODE=compose" >> "$GITHUB_ENV"
echo "Detected: docker-compose.yml"
else
echo "No Dockerfile or docker-compose.yml found. Skipping."
echo "SKIP_DOCKER=true" >> "$GITHUB_ENV"
fi
# -----------------------------------------------------------------------
# Step 5: Derive image owner and name dynamically
#
# Logic:
# FULL_REPO is derived from (in priority order):
# 1. $GITEA_REPOSITORY (Gitea native env var)
# 2. github.repository (Gitea Actions compatibility fallback)
# Format: "owner/repo"
#
# If IMAGE_OWNER=auto → use the owner part
# If IMAGE_NAME=auto → use the repo part
# Otherwise, use the explicit config values.
# -----------------------------------------------------------------------
- name: Derive image naming
if: env.SKIP_DOCKER != 'true'
run: |
# Determine FULL_REPO (owner/repo)
# Gitea Actions sets GITEA_REPOSITORY natively in some versions.
# It also maps github.repository for compatibility.
FULL_REPO="${GITEA_REPOSITORY:-${{ github.repository }}}"
if [ -z "$FULL_REPO" ]; then
echo "ERROR: Could not determine repository (GITEA_REPOSITORY and github.repository are both empty)"
exit 1
fi
# Split into OWNER and REPO
OWNER="$(echo "$FULL_REPO" | cut -d'/' -f1)"
REPO="$(echo "$FULL_REPO" | cut -d'/' -f2)"
echo "Derived FULL_REPO=$FULL_REPO OWNER=$OWNER REPO=$REPO"
# Apply config overrides
if [ "$IMAGE_OWNER_CFG" = "auto" ]; then
FINAL_OWNER="$OWNER"
else
FINAL_OWNER="$IMAGE_OWNER_CFG"
fi
if [ "$IMAGE_NAME_CFG" = "auto" ]; then
FINAL_NAME="$REPO"
else
FINAL_NAME="$IMAGE_NAME_CFG"
fi
# Construct the full image reference (without tag)
IMAGE_REF="${REGISTRY_HOST}/${FINAL_OWNER}/${FINAL_NAME}"
echo "Image reference: ${IMAGE_REF}:<tag>"
echo "IMAGE_REF=${IMAGE_REF}" >> "$GITHUB_ENV"
# -----------------------------------------------------------------------
# Step 6: Determine tags based on trigger and strategy
#
# Tag rules:
# - PR: build only, tag = "pr-<number>" (local only)
# - Push to branch: tag = branch name (e.g., "main")
# - Tag v1.2.3: tag = "1.2.3"; also "latest" if strategy includes it
# -----------------------------------------------------------------------
- name: Determine tags
if: env.SKIP_DOCKER != 'true'
run: |
TAGS=""
SHOULD_PUSH=false
REF="${GITHUB_REF:-}"
echo "Event: ${{ github.event_name }}"
echo "Ref: ${REF}"
# --- Pull Request: build only ---
if [ "${{ github.event_name }}" = "pull_request" ]; then
TAGS="${IMAGE_REF}:pr-${{ github.event.number }}"
SHOULD_PUSH=false
echo "PR build — will NOT push"
# --- Tag push (v*) ---
elif echo "${REF}" | grep -qE '^refs/tags/v'; then
# Extract version: refs/tags/v1.2.3 → 1.2.3
VERSION="$(echo "${REF}" | sed 's|refs/tags/v||')"
TAGS="${IMAGE_REF}:${VERSION}"
# Optionally add :latest
if echo "$DOCKER_TAG_STRATEGY" | grep -qi 'latest'; then
TAGS="${TAGS},${IMAGE_REF}:latest"
fi
if [ "$DOCKER_PUSH" = "true" ] && [ "$DOCKER_PUSH_ON_TAG" = "true" ]; then
SHOULD_PUSH=true
fi
echo "Tag push — version=${VERSION}, push=${SHOULD_PUSH}"
# --- Branch push ---
elif echo "${REF}" | grep -q '^refs/heads/'; then
BRANCH="$(echo "${REF}" | sed 's|refs/heads/||')"
TAGS="${IMAGE_REF}:${BRANCH}"
if [ "$DOCKER_PUSH" = "true" ] && [ "$DOCKER_PUSH_ON_BRANCH" = "true" ]; then
SHOULD_PUSH=true
fi
echo "Branch push — branch=${BRANCH}, push=${SHOULD_PUSH}"
else
echo "Unknown ref type: ${REF}. Building with tag 'dev'."
TAGS="${IMAGE_REF}:dev"
SHOULD_PUSH=false
fi
echo "DOCKER_TAGS=${TAGS}" >> "$GITHUB_ENV"
echo "SHOULD_PUSH=${SHOULD_PUSH}" >> "$GITHUB_ENV"
echo "Final tags: ${TAGS}"
echo "Will push: ${SHOULD_PUSH}"
# -----------------------------------------------------------------------
# Step 7: Build the Docker image
# -----------------------------------------------------------------------
- name: Build Docker image
if: env.SKIP_DOCKER != 'true'
run: |
if [ "$DOCKER_MODE" = "dockerfile" ]; then
# Build with the first tag; additional tags are added after
PRIMARY_TAG="$(echo "$DOCKER_TAGS" | cut -d',' -f1)"
echo ">>> docker build -t ${PRIMARY_TAG} ."
docker build -t "${PRIMARY_TAG}" .
# Tag additional images if present
IFS=',' read -ra TAG_ARRAY <<< "$DOCKER_TAGS"
for tag in "${TAG_ARRAY[@]:1}"; do
echo ">>> docker tag ${PRIMARY_TAG} ${tag}"
docker tag "${PRIMARY_TAG}" "${tag}"
done
elif [ "$DOCKER_MODE" = "compose" ]; then
echo ">>> docker compose build"
docker compose build
fi
# -----------------------------------------------------------------------
# Step 8: Login to registry (only when pushing)
#
# Uses PAT-based auth. Requires secrets:
# REGISTRY_USERNAME — Gitea username or bot
# REGISTRY_TOKEN — PAT with package:write scope
# -----------------------------------------------------------------------
- name: Login to container registry
if: env.SKIP_DOCKER != 'true' && env.SHOULD_PUSH == 'true'
run: |
echo "Logging in to ${REGISTRY_HOST}..."
# Use --password-stdin to avoid leaking the token in process list
echo "${{ secrets.REGISTRY_TOKEN }}" | \
docker login "${REGISTRY_HOST}" \
-u "${{ secrets.REGISTRY_USERNAME }}" \
--password-stdin
# -----------------------------------------------------------------------
# Step 9: Push Docker image(s)
# -----------------------------------------------------------------------
- name: Push Docker image
if: env.SKIP_DOCKER != 'true' && env.SHOULD_PUSH == 'true'
run: |
IFS=',' read -ra TAG_ARRAY <<< "$DOCKER_TAGS"
for tag in "${TAG_ARRAY[@]}"; do
echo ">>> docker push ${tag}"
docker push "${tag}"
done
# -----------------------------------------------------------------------
# Step 10: Summary
# -----------------------------------------------------------------------
- name: Docker Summary
if: always()
run: |
echo "=============================="
echo " Docker Workflow Complete"
echo " Mode: ${DOCKER_MODE:-skipped}"
echo " Tags: ${DOCKER_TAGS:-none}"
echo " Pushed: ${SHOULD_PUSH:-false}"
echo "=============================="

View File

@@ -0,0 +1,107 @@
# =============================================================================
# Renovate Workflow — Automated Dependency Updates
# =============================================================================
#
# DISABLED BY DEFAULT (ENABLE_RENOVATE=false in .ci/config.env).
#
# When enabled, this workflow runs Renovate to:
# - Detect outdated dependencies (pip, npm, Docker FROM, etc.)
# - Open PRs with updates, respecting schedule and PR limits
#
# REQUIRED SECRET:
# RENOVATE_TOKEN — A Gitea PAT (Personal Access Token) with repo scope
# for the Renovate bot user. Set in repo/org secrets.
#
# CONFIG:
# - .ci/config.env → RENOVATE_SCHEDULE, RENOVATE_PR_LIMIT
# - renovate.json → Renovate-specific config (grouping, labels, etc.)
#
# See docs/RENOVATE.md for setup instructions.
# =============================================================================
name: Renovate
on:
# Run on a schedule (default: weekly on Mondays at 04:00 UTC)
schedule:
- cron: "0 4 * * 1"
# Allow manual trigger
workflow_dispatch:
jobs:
renovate:
runs-on: ubuntu-latest
steps:
# -----------------------------------------------------------------------
# Step 1: Checkout
# -----------------------------------------------------------------------
- name: Checkout
uses: actions/checkout@v4
# -----------------------------------------------------------------------
# Step 2: Load config
# -----------------------------------------------------------------------
- name: Load config
run: |
if [ -f .ci/config.env ]; then
set -a
source .ci/config.env
set +a
fi
echo "ENABLE_RENOVATE=${ENABLE_RENOVATE:-false}" >> "$GITHUB_ENV"
echo "RENOVATE_SCHEDULE=${RENOVATE_SCHEDULE:-weekly}" >> "$GITHUB_ENV"
echo "RENOVATE_PR_LIMIT=${RENOVATE_PR_LIMIT:-5}" >> "$GITHUB_ENV"
# -----------------------------------------------------------------------
# Step 3: Check if Renovate is enabled
# -----------------------------------------------------------------------
- name: Check if enabled
run: |
if [ "$ENABLE_RENOVATE" != "true" ]; then
echo "Renovate is disabled (ENABLE_RENOVATE=$ENABLE_RENOVATE)."
echo "To enable, set ENABLE_RENOVATE=true in .ci/config.env"
echo "SKIP_RENOVATE=true" >> "$GITHUB_ENV"
fi
# -----------------------------------------------------------------------
# Step 4: Run Renovate
#
# Uses the official Renovate CLI via npx. Configures it to point at
# the Gitea instance and the current repository.
# -----------------------------------------------------------------------
- name: Run Renovate
if: env.SKIP_RENOVATE != 'true'
env:
RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }}
run: |
if [ -z "$RENOVATE_TOKEN" ]; then
echo "ERROR: RENOVATE_TOKEN secret is not set."
echo "Please create a Gitea PAT and add it as a repository secret."
exit 1
fi
# Determine repository path
FULL_REPO="${GITEA_REPOSITORY:-${{ github.repository }}}"
echo "Running Renovate for ${FULL_REPO} on ${REGISTRY_HOST:-git.hiddenden.cafe}..."
npx renovate \
--platform gitea \
--endpoint "https://${REGISTRY_HOST:-git.hiddenden.cafe}/api/v1" \
--token "$RENOVATE_TOKEN" \
--pr-hourly-limit "$RENOVATE_PR_LIMIT" \
"$FULL_REPO"
# -----------------------------------------------------------------------
# Step 5: Summary
# -----------------------------------------------------------------------
- name: Renovate Summary
if: always()
run: |
echo "=============================="
echo " Renovate Workflow Complete"
echo " Enabled: ${ENABLE_RENOVATE:-false}"
echo " Schedule: ${RENOVATE_SCHEDULE:-weekly}"
echo " PR Limit: ${RENOVATE_PR_LIMIT:-5}"
echo "=============================="

View File

@@ -0,0 +1,211 @@
# =============================================================================
# Security Workflow — Secret Scanning & Vulnerability Detection
# =============================================================================
#
# DISABLED BY DEFAULT (ENABLE_SECURITY=false in .ci/config.env).
#
# When enabled, this workflow runs:
# 1. gitleaks — scans for hardcoded secrets in the repo
# 2. osv-scanner — checks dependencies for known vulnerabilities
# 3. trivy — scans Docker images for CVEs (if a built image exists)
#
# STRICT_SECURITY=true → any finding fails the workflow
# STRICT_SECURITY=false → findings are logged as warnings (default)
#
# This is "best effort" — tools that aren't available are skipped.
# See docs/SECURITY.md for full details.
# =============================================================================
name: Security
on:
push:
branches:
- main
pull_request:
jobs:
security:
runs-on: ubuntu-latest
steps:
# -----------------------------------------------------------------------
# Step 1: Checkout
# -----------------------------------------------------------------------
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
# -----------------------------------------------------------------------
# Step 2: Load configuration
# -----------------------------------------------------------------------
- name: Load config
run: |
if [ -f .ci/config.env ]; then
set -a
source .ci/config.env
set +a
fi
echo "ENABLE_SECURITY=${ENABLE_SECURITY:-false}" >> "$GITHUB_ENV"
echo "STRICT_SECURITY=${STRICT_SECURITY:-false}" >> "$GITHUB_ENV"
# -----------------------------------------------------------------------
# Step 3: Check if security scanning is enabled
# -----------------------------------------------------------------------
- name: Check if enabled
run: |
if [ "$ENABLE_SECURITY" != "true" ]; then
echo "Security scanning is disabled (ENABLE_SECURITY=$ENABLE_SECURITY)."
echo "To enable, set ENABLE_SECURITY=true in .ci/config.env"
echo "SKIP_SECURITY=true" >> "$GITHUB_ENV"
fi
# -----------------------------------------------------------------------
# Step 4: Gitleaks — Secret scanning
#
# Scans the git history for accidentally committed secrets
# (API keys, passwords, tokens, etc.)
# -----------------------------------------------------------------------
- name: Run gitleaks
if: env.SKIP_SECURITY != 'true'
run: |
FINDINGS=0
# Install gitleaks
echo "Installing gitleaks..."
GITLEAKS_VERSION="8.18.4"
curl -sSfL "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" | \
tar xz -C /usr/local/bin gitleaks || {
echo "WARNING: Failed to install gitleaks, skipping secret scan."
exit 0
}
echo ">>> gitleaks detect"
if ! gitleaks detect --source . --verbose; then
FINDINGS=1
echo "gitleaks found potential secrets!"
fi
if [ "$FINDINGS" -ne 0 ]; then
if [ "$STRICT_SECURITY" = "true" ]; then
echo "ERROR: Secret scan found issues (STRICT_SECURITY=true)"
exit 1
else
echo "WARNING: Secret scan found issues (STRICT_SECURITY=false, continuing)"
fi
else
echo "gitleaks: no secrets found."
fi
# -----------------------------------------------------------------------
# Step 5: OSV-Scanner — Dependency vulnerability scanning
#
# Checks lockfiles (requirements.txt, package-lock.json, etc.) against
# the OSV database for known vulnerabilities.
# -----------------------------------------------------------------------
- name: Run osv-scanner
if: env.SKIP_SECURITY != 'true'
run: |
FINDINGS=0
# Check if there's anything to scan
HAS_DEPS=false
for f in requirements.txt package-lock.json yarn.lock pnpm-lock.yaml go.sum Cargo.lock; do
if [ -f "$f" ]; then
HAS_DEPS=true
break
fi
done
if [ "$HAS_DEPS" = "false" ]; then
echo "SKIP: No dependency lockfiles found for osv-scanner."
exit 0
fi
# Install osv-scanner
echo "Installing osv-scanner..."
OSV_VERSION="1.8.3"
curl -sSfL "https://github.com/google/osv-scanner/releases/download/v${OSV_VERSION}/osv-scanner_linux_amd64" \
-o /usr/local/bin/osv-scanner && chmod +x /usr/local/bin/osv-scanner || {
echo "WARNING: Failed to install osv-scanner, skipping."
exit 0
}
echo ">>> osv-scanner --recursive ."
if ! osv-scanner --recursive .; then
FINDINGS=1
echo "osv-scanner found vulnerabilities!"
fi
if [ "$FINDINGS" -ne 0 ]; then
if [ "$STRICT_SECURITY" = "true" ]; then
echo "ERROR: Dependency scan found issues (STRICT_SECURITY=true)"
exit 1
else
echo "WARNING: Dependency scan found issues (STRICT_SECURITY=false, continuing)"
fi
else
echo "osv-scanner: no vulnerabilities found."
fi
# -----------------------------------------------------------------------
# Step 6: Trivy — Container image scanning
#
# Scans a Docker image for OS and library CVEs.
# Only runs if a Dockerfile exists (assumes image was built).
# -----------------------------------------------------------------------
- name: Run trivy
if: env.SKIP_SECURITY != 'true'
run: |
if [ ! -f Dockerfile ]; then
echo "SKIP: No Dockerfile found, skipping Trivy image scan."
exit 0
fi
FINDINGS=0
# Install trivy
echo "Installing trivy..."
curl -sSfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | \
sh -s -- -b /usr/local/bin || {
echo "WARNING: Failed to install trivy, skipping."
exit 0
}
# Build the image first so Trivy can scan it
IMAGE_TAG="security-scan:local"
echo ">>> docker build -t ${IMAGE_TAG} ."
docker build -t "${IMAGE_TAG}" . || {
echo "WARNING: Docker build failed, skipping Trivy scan."
exit 0
}
echo ">>> trivy image ${IMAGE_TAG}"
if ! trivy image --exit-code 1 --severity HIGH,CRITICAL "${IMAGE_TAG}"; then
FINDINGS=1
echo "Trivy found vulnerabilities in the Docker image!"
fi
if [ "$FINDINGS" -ne 0 ]; then
if [ "$STRICT_SECURITY" = "true" ]; then
echo "ERROR: Image scan found issues (STRICT_SECURITY=true)"
exit 1
else
echo "WARNING: Image scan found issues (STRICT_SECURITY=false, continuing)"
fi
else
echo "trivy: no HIGH/CRITICAL vulnerabilities found."
fi
# -----------------------------------------------------------------------
# Step 7: Summary
# -----------------------------------------------------------------------
- name: Security Summary
if: always()
run: |
echo "=============================="
echo " Security Workflow Complete"
echo " Enabled: ${ENABLE_SECURITY:-false}"
echo " Strict: ${STRICT_SECURITY:-false}"
echo "=============================="