From f22e3fabe64251a0d547bd9254f507de8082db9c Mon Sep 17 00:00:00 2001 From: "dolgolyov.alexei" Date: Mon, 23 Mar 2026 01:17:28 +0300 Subject: [PATCH] docs: add Gitea Python CI/CD guide Reusable reference extracted from wled-screen-controller covering: - Lint/test and release workflows for Gitea Actions - Cross-building Windows from Linux (embedded Python + wheels) - NSIS installer packaging - Docker multi-stage builds with Gitea registry - Version detection, pre-release handling, size optimization --- README.md | 4 + gitea-python-ci-cd.md | 455 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 459 insertions(+) create mode 100644 gitea-python-ci-cd.md diff --git a/README.md b/README.md index fb5efe9..99b5ce3 100644 --- a/README.md +++ b/README.md @@ -35,3 +35,7 @@ Fast code search skill for Claude Code — find classes, symbols, usages, implem ### [Everything Claude Code (ECC) — Setup Guide](ecc-setup-guide.md) Step-by-step instructions for setting up [Everything Claude Code](https://github.com/affaan-m/everything-claude-code) on a new machine — a comprehensive collection of skills, rules, agents, and hooks for Claude Code. + +### [CI/CD for Python Apps on Gitea](gitea-python-ci-cd.md) + +Reusable reference for building CI pipelines, release automation, and installer packaging for Python apps on Gitea — covering lint/test workflows, cross-compiled Windows builds (embedded Python + NSIS), Linux tarballs, Docker images, and Gitea REST API release management. diff --git a/gitea-python-ci-cd.md b/gitea-python-ci-cd.md new file mode 100644 index 0000000..5a39934 --- /dev/null +++ b/gitea-python-ci-cd.md @@ -0,0 +1,455 @@ +# CI/CD for Python Apps on Gitea + +A reusable reference for building CI pipelines, release automation, and installer packaging for Python-based applications hosted on Gitea. Extracted from a production project using Gitea Actions (GitHub Actions-compatible). + +## Prerequisites + +- Gitea instance with Actions enabled +- Runner(s) tagged `ubuntu-latest` (e.g., TrueNAS-hosted Gitea runners) +- `GITEA_TOKEN` secret configured in the repository (Settings > Secrets) + +## Pipeline Architecture + +Two workflows, triggered by different events: + +``` +push/PR to master ──► test.yml (lint + test) +push tag v* ──► release.yml (build + release + Docker) +``` + +## 1. Lint & Test Workflow + +**Trigger:** Push to `master` or pull request. + +```yaml +# .gitea/workflows/test.yml +name: Lint & Test + +on: + push: + branches: [master] + pull_request: + branches: [master] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends + + - name: Install dependencies + working-directory: server + run: | + pip install --upgrade pip + pip install -e ".[dev]" + + - name: Lint + working-directory: server + run: ruff check src/ tests/ + + - name: Test + working-directory: server + run: pytest --tb=short -q +``` + +**Key points:** + +- Keep the test job fast — lint first, then test +- Use `ruff` for linting (fast, replaces flake8 + isort + pyupgrade) +- Configure pytest in `pyproject.toml` with coverage: + +```toml +[tool.pytest.ini_options] +testpaths = ["tests"] +asyncio_mode = "auto" +addopts = "-v --cov=your_package --cov-report=html --cov-report=term" +``` + +## 2. Release Workflow + +**Trigger:** Push a tag matching `v*` (e.g., `v0.2.0`, `v0.2.0-alpha.1`). + +### 2.1. Create Release Job + +Creates a Gitea release via REST API with a formatted description. + +```yaml +create-release: + runs-on: ubuntu-latest + outputs: + release_id: ${{ steps.create.outputs.release_id }} + steps: + - name: Create Gitea release + id: create + env: + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + run: | + TAG="${{ gitea.ref_name }}" + BASE_URL="${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}" + + # Detect pre-release + IS_PRE="false" + if echo "$TAG" | grep -qE '(alpha|beta|rc)'; then + IS_PRE="true" + fi + + # Build release body (use Python to avoid YAML escaping issues) + BODY_JSON=$(python3 -c " + import json, textwrap + tag = '$TAG' + body = '''## Downloads + | Platform | File | + |----------|------| + | Windows (installer) | \`App-{tag}-setup.exe\` | + | Windows (portable) | \`App-{tag}-win-x64.zip\` | + | Linux | \`App-{tag}-linux-x64.tar.gz\` | + ''' + print(json.dumps(textwrap.dedent(body).strip())) + ") + + RELEASE=$(curl -s -X POST "\$BASE_URL/releases" \ + -H "Authorization: token \$GITEA_TOKEN" \ + -H "Content-Type: application/json" \ + -d "{ + \"tag_name\": \"\$TAG\", + \"name\": \"App \$TAG\", + \"body\": \$BODY_JSON, + \"draft\": false, + \"prerelease\": \$IS_PRE + }") + + RELEASE_ID=$(echo "\$RELEASE" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])") + echo "release_id=\$RELEASE_ID" >> "\$GITHUB_OUTPUT" +``` + +**Key points:** + +- Gitea uses `${{ gitea.ref_name }}` instead of GitHub's `${{ github.ref_name }}` +- Use `${{ gitea.server_url }}` and `${{ gitea.repository }}` for API URLs +- Output vars use `$GITHUB_OUTPUT` (Gitea Actions is compatible with GitHub Actions syntax) +- Use Python for body templating to avoid shell/YAML escaping hell + +### 2.2. Build Jobs (Parallel) + +All build jobs run in parallel, gated on `create-release`: + +```yaml +build-windows: + needs: create-release + runs-on: ubuntu-latest + # ... + +build-linux: + needs: create-release + runs-on: ubuntu-latest + # ... + +build-docker: + needs: create-release + runs-on: ubuntu-latest + # ... +``` + +### 2.3. Upload Assets to Gitea Release + +```yaml +- name: Attach assets to release + env: + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + run: | + RELEASE_ID="${{ needs.create-release.outputs.release_id }}" + BASE_URL="${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}" + + FILE=$(ls build/App-*.zip | head -1) + curl -s -X POST \ + "$BASE_URL/releases/$RELEASE_ID/assets?name=$(basename "$FILE")" \ + -H "Authorization: token $GITEA_TOKEN" \ + -H "Content-Type: application/octet-stream" \ + --data-binary "@$FILE" +``` + +## 3. Version Detection Pattern + +A robust fallback chain for detecting the version in build scripts: + +```bash +# 1. CLI argument +VERSION="${1:-}" + +# 2. Exact git tag +if [ -z "$VERSION" ]; then + VERSION=$(git describe --tags --exact-match 2>/dev/null || true) +fi + +# 3. CI environment variable (Gitea or GitHub) +if [ -z "$VERSION" ]; then + VERSION="${GITEA_REF_NAME:-${GITHUB_REF_NAME:-}}" +fi + +# 4. Hardcoded in source +if [ -z "$VERSION" ]; then + VERSION=$(grep -oP '__version__\s*=\s*"\K[^"]+' \ + src/your_package/__init__.py 2>/dev/null || echo "0.0.0") +fi + +VERSION_CLEAN="${VERSION#v}" # Strip leading 'v' +``` + +## 4. Cross-Building Windows from Linux + +No Windows runner needed. Download Windows embedded Python and win_amd64 wheels on Linux. + +### 4.1. Embedded Python Setup + +```bash +# Download Windows embedded Python +PYTHON_VERSION="3.11.9" +curl -sL "https://www.python.org/ftp/python/${PYTHON_VERSION}/python-${PYTHON_VERSION}-embed-amd64.zip" \ + -o python-embed.zip +unzip -qo python-embed.zip -d dist/python + +# Patch ._pth to enable site-packages +PTH_FILE=$(ls dist/python/python*._pth | head -1) +sed -i 's/^#\s*import site/import site/' "$PTH_FILE" +echo 'Lib\site-packages' >> "$PTH_FILE" +echo '../app/src' >> "$PTH_FILE" # App source on PYTHONPATH +``` + +**Why embedded Python?** It's a minimal, standalone Python distribution (~15 MB) without an installer. Perfect for bundling into portable apps. + +### 4.2. Cross-Platform Wheel Download + +```bash +WHEEL_DIR="build/win-wheels" +SITE_PACKAGES="dist/python/Lib/site-packages" +mkdir -p "$WHEEL_DIR" "$SITE_PACKAGES" + +# Download win_amd64 wheels (fallback to source for pure Python) +for dep in "fastapi>=0.115.0" "uvicorn>=0.32.0" "numpy>=2.1.3"; do + pip download --quiet --dest "$WHEEL_DIR" \ + --platform win_amd64 --python-version "3.11" \ + --implementation cp --only-binary :all: \ + "$dep" 2>/dev/null || \ + pip download --quiet --dest "$WHEEL_DIR" "$dep" +done + +# Install wheels into site-packages (just unzip — .whl is a zip) +for whl in "$WHEEL_DIR"/*.whl; do + unzip -qo "$whl" -d "$SITE_PACKAGES" +done +``` + +### 4.3. Size Optimization + +Strip unnecessary files from site-packages to reduce archive size: + +```bash +find "$SITE_PACKAGES" -type d -name __pycache__ -exec rm -rf {} + +find "$SITE_PACKAGES" -type d -name tests -exec rm -rf {} + +find "$SITE_PACKAGES" -type d -name "*.dist-info" -exec rm -rf {} + +find "$SITE_PACKAGES" -name "*.pyi" -delete + +# Remove build-time-only packages +rm -rf "$SITE_PACKAGES"/{pip,setuptools,pkg_resources}* + +# Remove heavy unused parts of specific libraries +rm -f "$SITE_PACKAGES"/cv2/opencv_videoio_ffmpeg*.dll # -28 MB +rm -rf "$SITE_PACKAGES"/numpy/{tests,f2py,typing} +``` + +### 4.4. Bundling tkinter (Optional) + +Embedded Python doesn't include tkinter. Extract it from the official MSI packages: + +```bash +# Requires: apt install msitools +MSI_BASE="https://www.python.org/ftp/python/${PYTHON_VERSION}/amd64" +curl -sL "$MSI_BASE/tcltk.msi" -o tcltk.msi +curl -sL "$MSI_BASE/lib.msi" -o lib.msi + +msiextract tcltk.msi # → _tkinter.pyd, tcl86t.dll, tk86t.dll, tcl8.6/, tk8.6/ +msiextract lib.msi # → tkinter/ Python package + +# Copy to embedded Python directory +cp _tkinter.pyd tcl86t.dll tk86t.dll dist/python/ +cp -r tkinter dist/python/Lib/ +cp -r tcl8.6 tk8.6 dist/python/ +``` + +## 5. Linux Build (venv-based) + +```bash +# Create self-contained virtualenv +python3 -m venv dist/venv +source dist/venv/bin/activate +pip install ".[camera,notifications]" + +# Remove the installed package (PYTHONPATH handles app code at runtime) +rm -rf dist/venv/lib/python*/site-packages/your_package* + +# Launcher script +cat > dist/run.sh << 'EOF' +#!/usr/bin/env bash +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +export PYTHONPATH="$SCRIPT_DIR/app/src" +source "$SCRIPT_DIR/venv/bin/activate" +exec python -m your_package.main +EOF +chmod +x dist/run.sh + +# Package +tar -czf "App-v${VERSION}-linux-x64.tar.gz" dist/ +``` + +### 5.1. Systemd Service Script + +Include install/uninstall scripts for running as a service: + +```bash +# install-service.sh creates: +# /etc/systemd/system/your-app.service +# Type=simple, Restart=on-failure, RestartSec=5 +# ExecStart=/path/to/run.sh +# Then: systemctl daemon-reload && systemctl enable && systemctl start +``` + +## 6. NSIS Installer (Windows) + +Cross-compilable on Linux: `apt install nsis && makensis installer.nsi` + +Key design decisions: + +```nsi +; User-scoped install (no admin required) +InstallDir "$LOCALAPPDATA\${APPNAME}" +RequestExecutionLevel user + +; Sections +Section "!Core (required)" SecCore ; App files + uninstaller +Section "Desktop shortcut" SecDesktop ; Optional +Section "Start with Windows" SecAutostart ; Optional — Startup folder shortcut + +; Uninstall preserves user data +; RMDir /r $INSTDIR\python, $INSTDIR\app, $INSTDIR\logs +; But NOT $INSTDIR\data (user config) +``` + +**CI dependencies:** `sudo apt-get install -y nsis msitools zip` + +Build: `makensis -DVERSION="${VERSION}" installer.nsi` + +## 7. Docker Build + +### Multi-stage Dockerfile + +```dockerfile +# Stage 1: Build frontend +FROM node:20-slim AS frontend +WORKDIR /app +COPY package.json package-lock.json ./ +RUN npm ci +COPY . . +RUN npm run build + +# Stage 2: Runtime +FROM python:3.11-slim +RUN apt-get update && apt-get install -y --no-install-recommends \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app +COPY pyproject.toml . +RUN pip install --no-cache-dir ".[notifications]" +COPY src/ src/ +COPY --from=frontend /app/src/your_package/static/ src/your_package/static/ + +RUN useradd -r -u 1000 appuser +USER appuser + +HEALTHCHECK --interval=30s --timeout=5s CMD curl -f http://localhost:8080/health || exit 1 +EXPOSE 8080 +CMD ["uvicorn", "your_package.main:app", "--host", "0.0.0.0", "--port", "8080"] +``` + +### Docker in CI (Gitea Registry) + +```yaml +- name: Login to Gitea Container Registry + id: docker-login + continue-on-error: true # Graceful degradation if registry unavailable + run: | + echo "${{ secrets.GITEA_TOKEN }}" | docker login \ + "$SERVER_HOST" -u "${{ gitea.actor }}" --password-stdin + +- name: Build and tag + if: steps.docker-login.outcome == 'success' + run: | + docker build -t "$REGISTRY:$TAG" -t "$REGISTRY:$VERSION" ./server + # Tag as 'latest' only for stable releases + if ! echo "$TAG" | grep -qE '(alpha|beta|rc)'; then + docker tag "$REGISTRY:$TAG" "$REGISTRY:latest" + fi + +- name: Push + if: steps.docker-login.outcome == 'success' + run: docker push "$REGISTRY" --all-tags +``` + +**Registry URL pattern:** `{gitea-host}/{owner}/{repo}:{tag}` + +## 8. Release Versioning + +| Tag format | Example | Pre-release? | Docker `:latest`? | +|---|---|---|---| +| `v{major}.{minor}.{patch}` | `v0.2.0` | No | Yes | +| `v{major}.{minor}.{patch}-alpha.{n}` | `v0.2.0-alpha.1` | Yes | No | +| `v{major}.{minor}.{patch}-beta.{n}` | `v0.2.0-beta.2` | Yes | No | +| `v{major}.{minor}.{patch}-rc.{n}` | `v0.2.0-rc.1` | Yes | No | + +**Release process:** + +```bash +# Stable release +git tag v0.2.0 +git push origin v0.2.0 + +# Pre-release +git tag v0.2.0-alpha.1 +git push origin v0.2.0-alpha.1 +``` + +## 9. Gitea vs GitHub Actions Differences + +| Feature | GitHub Actions | Gitea Actions | +|---|---|---| +| Context prefix | `github.*` | `gitea.*` | +| Ref name | `${{ github.ref_name }}` | `${{ gitea.ref_name }}` | +| Server URL | `${{ github.server_url }}` | `${{ gitea.server_url }}` | +| Repository | `${{ github.repository }}` | `${{ gitea.repository }}` | +| Actor | `${{ github.actor }}` | `${{ gitea.actor }}` | +| SHA | `${{ github.sha }}` | `${{ gitea.sha }}` | +| Output vars | `$GITHUB_OUTPUT` | `$GITHUB_OUTPUT` (same) | +| Secrets | `${{ secrets.NAME }}` | `${{ secrets.NAME }}` (same) | +| Docker Buildx | Available | May not work (runner networking) | + +## 10. Checklist for New Projects + +- [ ] Create `.gitea/workflows/test.yml` — lint + test on push/PR +- [ ] Create `.gitea/workflows/release.yml` — build + release on `v*` tag +- [ ] Add `GITEA_TOKEN` secret to repository +- [ ] Set up version detection in build scripts (tag → env → source) +- [ ] Create `build-dist.sh` for Linux (venv + tarball) +- [ ] Create `build-dist-windows.sh` for Windows (embedded Python + ZIP) +- [ ] Create `installer.nsi` for Windows installer (optional) +- [ ] Create `Dockerfile` for Docker builds (optional) +- [ ] Configure `pyproject.toml` with `[tool.ruff]` and `[tool.pytest]` +- [ ] Set up `.pre-commit-config.yaml` with ruff + black