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_TOKENsecret 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
rufffor linting (fast, replaces flake8 + isort + pyupgrade) - Configure pytest in
pyproject.tomlwith 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 = f'''## 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 onv*tag - Add
GITEA_TOKENsecret to repository - Set up version detection in build scripts (tag → env → source)
- Create
build-dist.shfor Linux (venv + tarball) - Create
build-dist-windows.shfor Windows (embedded Python + ZIP) - Create
installer.nsifor Windows installer (optional) - Create
Dockerfilefor Docker builds (optional) - Configure
pyproject.tomlwith[tool.ruff]and[tool.pytest] - Set up
.pre-commit-config.yamlwith 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