Document the VBS wrapper approach for launching Windows apps without console window flash. Update NSIS example to prefer VBS over direct bat execution.
513 lines
15 KiB
Markdown
513 lines
15 KiB
Markdown
# 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 <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:
|
|
|
|
```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
|
|
|
|
; Optional: launch app after install (checkbox on finish page)
|
|
; Direct bat approach (shows brief console flash):
|
|
; !define MUI_FINISHPAGE_RUN "$INSTDIR\MyApp.bat"
|
|
; Preferred: VBS hidden launcher (no console window at all):
|
|
!define MUI_FINISHPAGE_RUN "wscript.exe"
|
|
!define MUI_FINISHPAGE_RUN_PARAMETERS '"$INSTDIR\scripts\start-hidden.vbs"'
|
|
!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)
|
|
```
|
|
|
|
### 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:
|
|
|
|
```vbs
|
|
Set WshShell = CreateObject("WScript.Shell")
|
|
scriptDir = CreateObject("Scripting.FileSystemObject").GetParentFolderName(WScript.ScriptFullName)
|
|
appRoot = CreateObject("Scripting.FileSystemObject").GetParentFolderName(scriptDir)
|
|
WshShell.CurrentDirectory = appRoot
|
|
' Run bat completely hidden (0 = hidden, False = don't wait)
|
|
WshShell.Run """" & appRoot & "\MyApp.bat""", 0, False
|
|
```
|
|
|
|
Place in `scripts/start-hidden.vbs` and bundle it in the build script.
|
|
All NSIS shortcuts use: `"wscript.exe" '"$INSTDIR\scripts\start-hidden.vbs"'`
|
|
|
|
**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 <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)
|
|
|
|
```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
|
|
```
|