# 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: ```text 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 }") # Fallback: if release already exists for this tag, fetch it instead RELEASE_ID=$(echo "\$RELEASE" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])" 2>/dev/null) if [ -z "\$RELEASE_ID" ]; then echo "::warning::Release already exists for tag \$TAG — reusing existing release" RELEASE=$(curl -s "\$BASE_URL/releases/tags/\$TAG" \ -H "Authorization: token \$GITEA_TOKEN") RELEASE_ID=$(echo "\$RELEASE" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])") fi 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 ## 11. Troubleshooting ### Release already exists for tag If the `create-release` job fails with `KeyError: 'id'`, the Gitea API returned an error because a release already exists for that tag (e.g., from a previous failed run). **Prevention:** The fallback logic in section 2.1 handles this automatically — if creation fails, it fetches the existing release by tag and reuses its ID. A `::warning::` annotation is emitted in the workflow log. **Re-triggering a failed release workflow:** ```bash # Option A: Delete and re-push the same tag git push origin :refs/tags/v0.1.0-alpha.2 # delete remote tag # Delete the release in Gitea UI or via API git tag -f v0.1.0-alpha.2 # recreate local tag git push origin v0.1.0-alpha.2 # push again # Option B: Just bump the version (simpler) git tag v0.1.0-alpha.3 git push origin v0.1.0-alpha.3 ```