Files
homelab-optimized/docs/admin/GITEA_ACTIONS_GUIDE.md
Gitea Mirror Bot f90b6dd93f
Some checks failed
Documentation / Deploy to GitHub Pages (push) Has been cancelled
Documentation / Build Docusaurus (push) Has been cancelled
Sanitized mirror from private repository - 2026-04-05 10:17:38 UTC
2026-04-05 10:17:38 +00:00

14 KiB

Gitea Actions & Runner Guide

How to use the calypso-runner for homelab automation

Overview

The calypso-runner is a Gitea Act Runner running on Calypso (gitea/act_runner:latest). It picks up jobs from any workflow in any repo it's registered to and executes them in Docker containers. A single runner handles all workflows sequentially — for a homelab this is plenty.

Runner labels (what runs-on: values work):

runs-on: value Container used
ubuntu-latest node:20-bookworm
ubuntu-22.04 ubuntu:22.04
python python:3.11

Workflows go in .gitea/workflows/*.yml. They use the same syntax as GitHub Actions.


Existing workflows

File Trigger What it does
mirror-to-public.yaml push to main Sanitizes repo and force-pushes to homelab-optimized
validate.yml every push + PR YAML lint + secret scan on changed files
portainer-deploy.yml push to main (hosts/ changed) Auto-redeploys matching Portainer stacks
dns-audit.yml daily 08:00 UTC + manual DNS resolution, NPM↔DDNS cross-reference, CF proxy audit

Repo secrets

Stored at: Gitea → Vish/homelab → Settings → Secrets → Actions

Secret Used by Notes
PUBLIC_REPO_TOKEN mirror-to-public Write access to homelab-optimized
PUBLIC_REPO_URL mirror-to-public URL of the public mirror repo
PORTAINER_TOKEN portainer-deploy ptr_* Portainer API token
GIT_TOKEN portainer-deploy, dns-audit Gitea token for repo checkout + Portainer git auth
NTFY_URL portainer-deploy, dns-audit Full ntfy topic URL (optional)
NPM_EMAIL dns-audit NPM admin email for API login
NPM_PASSWORD dns-audit NPM admin password for API login
CF_TOKEN dns-audit Cloudflare API token (same one used by DDNS containers)
CF_SYNC dns-audit Set to true to auto-patch CF proxy mismatches (optional)

Note: Gitea reserves the GITEA_ prefix for built-in variables — use GIT_TOKEN not GITEA_TOKEN.


Workflow recipes

DNS record audit

This is a live workflow — see .gitea/workflows/dns-audit.yml and the full documentation at docs/guides/dns-audit.md.

It runs the script at .gitea/scripts/dns-audit.py which does a 5-step audit:

  1. Parses all DDNS compose files for the canonical domain + proxy-flag list
  2. Queries the NPM API for all proxy host domains
  3. Live DNS checks — proxied domains must resolve to CF IPs, unproxied to direct IPs
  4. Cross-references NPM ↔ DDNS (flags orphaned entries in either direction)
  5. Cloudflare API audit — checks proxy settings match DDNS config; auto-patches with CF_SYNC=true

Required secrets: GIT_TOKEN, NPM_EMAIL, NPM_PASSWORD, CF_TOKEN Optional: NTFY_URL (alert on failure), CF_SYNC=true (auto-patch mismatches)


Ansible dry-run on changed playbooks

Validates any Ansible playbook you change before it gets used in production. Requires your inventory to be reachable from the runner.

# .gitea/workflows/ansible-check.yml
name: Ansible Check

on:
  push:
    paths: ['ansible/**']
  pull_request:
    paths: ['ansible/**']

jobs:
  ansible-lint:
    runs-on: ubuntu-22.04
    steps:
      - uses: actions/checkout@v4

      - name: Install Ansible
        run: |
          apt-get update -q && apt-get install -y -q ansible ansible-lint

      - name: Syntax check changed playbooks
        run: |
          CHANGED=$(git diff --name-only HEAD~1 HEAD | grep 'ansible/.*\.yml$' || true)
          if [ -z "$CHANGED" ]; then
            echo "No playbooks changed"
            exit 0
          fi
          for playbook in $CHANGED; do
            echo "Checking: $playbook"
            ansible-playbook --syntax-check "$playbook" -i ansible/homelab/inventory/ || exit 1
          done

      - name: Lint changed playbooks
        run: |
          CHANGED=$(git diff --name-only HEAD~1 HEAD | grep 'ansible/.*\.yml$' || true)
          if [ -z "$CHANGED" ]; then exit 0; fi
          ansible-lint $CHANGED --exclude ansible/archive/

Notify on push

Sends an ntfy notification with a summary of every push to main — who pushed, what changed, and a link to the commit.

# .gitea/workflows/notify-push.yml
name: Notify on Push

on:
  push:
    branches: [main]

jobs:
  notify:
    runs-on: python
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 2

      - name: Send push notification
        env:
          NTFY_URL: ${{ secrets.NTFY_URL }}
        run: |
          python3 << 'PYEOF'
          import subprocess, requests, os

          ntfy_url = os.environ.get('NTFY_URL', '')
          if not ntfy_url:
              print("NTFY_URL not set, skipping")
              exit()

          author = subprocess.check_output(
              ['git', 'log', '-1', '--format=%an'], text=True).strip()
          message = subprocess.check_output(
              ['git', 'log', '-1', '--format=%s'], text=True).strip()
          changed = subprocess.check_output(
              ['git', 'diff', '--name-only', 'HEAD~1', 'HEAD'], text=True).strip()
          file_count = len(changed.splitlines()) if changed else 0
          sha = subprocess.check_output(
              ['git', 'rev-parse', '--short', 'HEAD'], text=True).strip()

          body = f"{message}\n{file_count} file(s) changed\nCommit: {sha}"
          requests.post(ntfy_url,
              data=body,
              headers={'Title': f'📦 Push by {author}', 'Priority': '2', 'Tags': 'inbox_tray'},
              timeout=10)
          print(f"Notified: {message}")
          PYEOF

Scheduled service health check

Pings all your services and sends an alert if any are down. Runs every 30 minutes.

# .gitea/workflows/health-check.yml
name: Service Health Check

on:
  schedule:
    - cron: '*/30 * * * *'   # every 30 minutes
  workflow_dispatch:

jobs:
  health:
    runs-on: python
    steps:
      - name: Check services
        env:
          NTFY_URL: ${{ secrets.NTFY_URL }}
        run: |
          pip install requests -q
          python3 << 'PYEOF'
          import requests, os, sys
          from requests.packages.urllib3.exceptions import InsecureRequestWarning
          requests.packages.urllib3.disable_warnings(InsecureRequestWarning)

          # Services to check: (name, url, expected_status)
          SERVICES = [
              ('Gitea',         'https://git.vish.gg',              200),
              ('Portainer',     'https://192.168.0.200:9443',        200),
              ('Authentik',     'https://sso.vish.gg',               200),
              ('Stoatchat',     'https://st.vish.gg',                200),
              ('Vaultwarden',   'https://vault.vish.gg',             200),
              ('Paperless',     'https://paperless.vish.gg',         200),
              ('Immich',        'https://photos.vish.gg',            200),
              ('Uptime Kuma',   'https://status.vish.gg',            200),
              # add more here
          ]

          down = []
          for name, url, expected in SERVICES:
              try:
                  r = requests.get(url, timeout=10, verify=False, allow_redirects=True)
                  if r.status_code == expected or r.status_code in [200, 301, 302, 401, 403]:
                      print(f"OK  {name} ({r.status_code})")
                  else:
                      down.append(f"{name}: HTTP {r.status_code}")
                      print(f"ERR {name}: HTTP {r.status_code}")
              except Exception as e:
                  down.append(f"{name}: unreachable ({e})")
                  print(f"ERR {name}: {e}")

          ntfy_url = os.environ.get('NTFY_URL', '')
          if down:
              if ntfy_url:
                  requests.post(ntfy_url,
                      data='\n'.join(down),
                      headers={'Title': '🚨 Services Down', 'Priority': '5', 'Tags': 'rotating_light'},
                      timeout=10)
              sys.exit(1)
          PYEOF

Backup verification

Checks that backup files on your NAS are recent and non-empty. Uses SSH to check file modification times.

# .gitea/workflows/backup-verify.yml
name: Backup Verification

on:
  schedule:
    - cron: '0 10 * * *'   # daily at 10:00 UTC (after nightly backups complete)
  workflow_dispatch:

jobs:
  verify:
    runs-on: ubuntu-22.04
    steps:
      - name: Check backups via SSH
        env:
          NTFY_URL: ${{ secrets.NTFY_URL }}
          SSH_KEY: ${{ secrets.BACKUP_SSH_KEY }}   # add this secret: private SSH key
        run: |
          # Write SSH key
          mkdir -p ~/.ssh
          echo "$SSH_KEY" > ~/.ssh/id_rsa
          chmod 600 ~/.ssh/id_rsa
          ssh-keyscan -H 192.168.0.200 >> ~/.ssh/known_hosts 2>/dev/null

          # Check that backup directories exist and have files modified in last 24h
          ssh -i ~/.ssh/id_rsa homelab@192.168.0.200 << 'SSHEOF'
            MAX_AGE_HOURS=24
            BACKUP_DIRS=(
              "/volume1/backups/paperless"
              "/volume1/backups/vaultwarden"
              "/volume1/backups/immich"
            )
            FAILED=0
            for dir in "${BACKUP_DIRS[@]}"; do
              RECENT=$(find "$dir" -newer /tmp/.timeref -name "*.tar*" -o -name "*.sql*" 2>/dev/null | head -1)
              if [ -z "$RECENT" ]; then
                echo "STALE: $dir (no recent backup found)"
                FAILED=1
              else
                echo "OK: $dir -> $(basename $RECENT)"
              fi
            done
            exit $FAILED
          SSHEOF

To use this, add a BACKUP_SSH_KEY secret containing the private key for a user with read access to your backup directories.


Docker image update check

Checks for newer versions of your key container images and notifies you without automatically pulling — gives you a heads-up to review before Watchtower does it.

# .gitea/workflows/image-check.yml
name: Image Update Check

on:
  schedule:
    - cron: '0 9 * * 1'   # every Monday at 09:00 UTC
  workflow_dispatch:

jobs:
  check:
    runs-on: python
    steps:
      - name: Check for image updates
        env:
          NTFY_URL: ${{ secrets.NTFY_URL }}
        run: |
          pip install requests -q
          python3 << 'PYEOF'
          import requests, os

          # Images to track: (friendly name, image, current tag)
          IMAGES = [
              ('Authentik',    'ghcr.io/goauthentik/server',   'latest'),
              ('Gitea',        'gitea/gitea',                  'latest'),
              ('Immich',       'ghcr.io/immich-app/immich-server', 'release'),
              ('Paperless',    'ghcr.io/paperless-ngx/paperless-ngx', 'latest'),
              ('Vaultwarden',  'vaultwarden/server',           'latest'),
              ('Stoatchat',    'ghcr.io/stoatchat/backend',    'latest'),
          ]

          updates = []
          for name, image, tag in IMAGES:
              try:
                  # Check Docker Hub or GHCR for latest digest
                  if image.startswith('ghcr.io/'):
                      repo = image[len('ghcr.io/'):]
                      r = requests.get(
                          f'https://ghcr.io/v2/{repo}/manifests/{tag}',
                          headers={'Accept': 'application/vnd.oci.image.index.v1+json'},
                          timeout=10)
                      digest = r.headers.get('Docker-Content-Digest', 'unknown')
                  else:
                      r = requests.get(
                          f'https://hub.docker.com/v2/repositories/{image}/tags/{tag}',
                          timeout=10).json()
                      digest = r.get('digest', 'unknown')
                  print(f"OK  {name}: {digest[:20]}...")
                  updates.append(f"{name}: {digest[:16]}...")
              except Exception as e:
                  print(f"ERR {name}: {e}")

          ntfy_url = os.environ.get('NTFY_URL', '')
          if ntfy_url and updates:
              requests.post(ntfy_url,
                  data='\n'.join(updates),
                  headers={'Title': '📋 Weekly Image Digest Check', 'Priority': '2', 'Tags': 'docker'},
                  timeout=10)
          PYEOF

How to add a new workflow

  1. Create a file in .gitea/workflows/yourname.yml
  2. Set runs-on: to one of: ubuntu-latest, ubuntu-22.04, or python
  3. Use ${{ secrets.SECRET_NAME }} for any tokens/passwords
  4. Push to main — the runner picks it up immediately
  5. View results: Gitea → Vish/homelab → Actions

How to run a workflow manually

Any workflow with workflow_dispatch: in its trigger can be run from the UI: Gitea → Vish/homelab → Actions → select workflow → Run workflow

Cron schedule reference

┌─ minute (0-59)
│  ┌─ hour (0-23, UTC)
│  │  ┌─ day of month (1-31)
│  │  │  ┌─ month (1-12)
│  │  │  │  ┌─ day of week (0=Sun, 6=Sat)
│  │  │  │  │
*  *  *  *  *

Examples:
  0 8 * * *      = daily at 08:00 UTC
  */30 * * * *   = every 30 minutes
  0 9 * * 1      = every Monday at 09:00 UTC
  0 2 * * 0      = every Sunday at 02:00 UTC

Debugging a failed workflow

# View runner logs on Calypso via Portainer API
curl -sk -H "X-API-Key: $PORTAINER_TOKEN" \
  "https://192.168.0.200:9443/api/endpoints/443397/docker/containers/json?all=true" | \
  jq -r '.[] | select(.Names[0]=="/gitea-runner") | .Id' | \
  xargs -I{} curl -sk -H "X-API-Key: $PORTAINER_TOKEN" \
  "https://192.168.0.200:9443/api/endpoints/443397/docker/containers/{}/logs?stdout=1&stderr=1&tail=50" | strings

Or view run results directly in the Gitea UI: Gitea → Vish/homelab → Actions → click any run