Files
claude-code-facts/gitea-python-ci-cd.md
dolgolyov.alexei bf7631b7e4 docs: add NSIS finish page launch option to CI/CD guide
Add optional MUI_FINISHPAGE_RUN pattern for launching the app
after installation completes.
2026-03-23 13:41:41 +03:00

14 KiB

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.

# .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 <your-system-deps>

      - 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:
[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.

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:

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

- 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:

# 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

# 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

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:

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:

# 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)

# 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:

# 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:

; User-scoped install (no admin required)
InstallDir "$LOCALAPPDATA\${APPNAME}"
RequestExecutionLevel user

; Optional: launch app after install (checkbox on finish page)
!define MUI_FINISHPAGE_RUN "$INSTDIR\MyApp.bat"
!define MUI_FINISHPAGE_RUN_TEXT "Launch ${APPNAME}"

; 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

# 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 <system-deps> \
    && 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)

- 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:

# 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:

# 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