Files
claude-code-facts/gitea-python-ci-cd.md
dolgolyov.alexei 294c50a2eb docs: expand CI/CD guide with NSIS launch function, VBS fallback, and local build testing
- Replace MUI_FINISHPAGE_RUN_PARAMETERS with RUN_FUNCTION (fixes quoting)
- Add embedded Python fallback to VBS hidden launcher
- Add .onInit file-lock detection for running instances
- Add section 11: local Windows build testing with NSIS
- Expand troubleshooting table with common build/install issues
2026-03-24 12:46:38 +03:00

19 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)
; IMPORTANT: Do NOT use MUI_FINISHPAGE_RUN with MUI_FINISHPAGE_RUN_PARAMETERS —
; NSIS Exec command chokes on the quoting. Use MUI_FINISHPAGE_RUN_FUNCTION instead:
!define MUI_FINISHPAGE_RUN ""
!define MUI_FINISHPAGE_RUN_TEXT "Launch ${APPNAME}"
!define MUI_FINISHPAGE_RUN_FUNCTION LaunchApp

Function LaunchApp
    ExecShell "open" "wscript.exe" '"$INSTDIR\scripts\start-hidden.vbs"'
    Sleep 2000
    ExecShell "open" "http://localhost:8765/"  ; Open Web UI after server starts
FunctionEnd

; Detect running instance before install (file lock check)
Function .onInit
    IfFileExists "$INSTDIR\python\python.exe" 0 done
    ClearErrors
    FileOpen $0 "$INSTDIR\python\python.exe" a
    IfErrors locked
    FileClose $0
    Goto done
    locked:
        MessageBox MB_YESNOCANCEL|MB_ICONEXCLAMATION \
            "${APPNAME} is currently running.$\n$\nYes = Stop and continue$\nNo = Continue anyway (may cause errors)$\nCancel = Abort" \
            IDYES kill IDNO done
        Abort
        kill:
            nsExec::ExecToLog 'wmic process where "ExecutablePath like $\'%AppName%python%$\'" call terminate'
            Sleep 2000
    done:
FunctionEnd

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

Hidden Launcher (VBS)

Bat files briefly flash a console window even with @echo off. To avoid this, use a VBS wrapper that all shortcuts and the finish page point to:

Set fso = CreateObject("Scripting.FileSystemObject")
Set WshShell = CreateObject("WScript.Shell")
scriptDir = fso.GetParentFolderName(WScript.ScriptFullName)
appRoot = fso.GetParentFolderName(scriptDir)
WshShell.CurrentDirectory = appRoot
' Use embedded Python if present (installed dist), otherwise system Python
embeddedPython = appRoot & "\python\python.exe"
If fso.FileExists(embeddedPython) Then
    WshShell.Run """" & embeddedPython & """ -m your_package.main", 0, False
Else
    WshShell.Run "python -m your_package.main", 0, False
End If

Place in scripts/start-hidden.vbs and bundle it in the build script. All NSIS shortcuts use: "wscript.exe" '"$INSTDIR\scripts\start-hidden.vbs"'

Important: The embedded Python fallback is critical — the installed distribution doesn't have Python on PATH, so python alone won't work. The dev environment uses system Python, so the fallback handles both cases.

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. Local Build Testing (Windows)

The CI builds on Linux, but you can build locally on Windows for faster iteration.

11.1. Prerequisites

Install NSIS (for installer builds):

# If winget is not on PATH, use the full path:
& "$env:LOCALAPPDATA\Microsoft\WindowsApps\winget.exe" install NSIS.NSIS
# Installs to: C:\Program Files (x86)\NSIS\makensis.exe

11.2. Build Steps

# 1. Build frontend
npm ci && npm run build

# 2. Build Windows distribution (from Git Bash)
bash build-dist-windows.sh v1.0.0

# 3. Build NSIS installer
"/c/Program Files (x86)/NSIS/makensis.exe" -DVERSION="1.0.0" installer.nsi
# Output: build/MediaServer-v1.0.0-setup.exe

11.3. Common Issues

Issue Cause Fix
zip: command not found Git Bash doesn't include zip Harmless — only affects the portable ZIP, not the installer. Install zip via MSYS2 if needed
Exec expects 1 parameters, got 2 MUI_FINISHPAGE_RUN_PARAMETERS quoting breaks NSIS Exec Use MUI_FINISHPAGE_RUN_FUNCTION instead (see section 6)
Error opening file for writing: ...python\\_asyncio.pyd Server is running and has DLLs locked Stop the server before installing. Add .onInit file-lock check (see section 6)
App doesn't start after install (Launch checkbox) VBS uses python but embedded Python isn't on PATH Use embedded Python fallback in VBS (see Hidden Launcher section)
winget not recognized winget.exe exists but isn't on shell PATH Use full path: $env:LOCALAPPDATA\Microsoft\WindowsApps\winget.exe
NSIS warning: install function not referenced MUI_FINISHPAGE_RUN define is missing MUI_FINISHPAGE_RUN_FUNCTION still requires MUI_FINISHPAGE_RUN to be defined (controls checkbox visibility). Set it to ""
dist/ has stale files after code changes build-dist-windows.sh copies from source at build time Re-run the full build script, or manually copy changed files into dist/

11.4. Iterating on Installer Only

If you only changed installer.nsi (not app code), skip the full rebuild:

# Just rebuild the installer using existing dist/
"/c/Program Files (x86)/NSIS/makensis.exe" -DVERSION="1.0.0" installer.nsi

If you changed app code or dependencies, you must re-run build-dist-windows.sh first — the dist/ directory is a snapshot and won't pick up source changes automatically.

12. Troubleshooting

Running server blocks installation

See section 11.3. The .onInit function in section 6 shows how to detect a locked python.exe and prompt the user before proceeding.

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