docs: add version management and in-app auto-update sections

- Section 10: Single source of truth via pyproject.toml + importlib.metadata,
  CI version stamping, Docker build args, updated fallback chain
- Section 11: Release provider abstraction, PEP 440 version normalization,
  install type detection, update service pattern, NSIS silent install,
  portable ZIP/tarball swap scripts, API endpoints, frontend integration
This commit is contained in:
2026-03-25 12:51:07 +03:00
parent 7b563c235e
commit 0a9c270c6b
+245 -8
View File
@@ -507,24 +507,259 @@ git push origin v0.2.0-alpha.1
| Secrets | `${{ secrets.NAME }}` | `${{ secrets.NAME }}` (same) |
| Docker Buildx | Available | May not work (runner networking) |
## 10. Checklist for New Projects
## 10. Version Management — Single Source of Truth
The version must be consistent across the UI, package metadata, installer filename, and Docker label. The simplest approach: **`pyproject.toml` is the single source of truth**, and everything else derives from it.
### 10.1. Runtime Version via `importlib.metadata`
Instead of hardcoding `__version__` in `__init__.py`, read it from installed package metadata:
```python
# src/your_package/__init__.py
from importlib.metadata import version, PackageNotFoundError
try:
__version__ = version("your-package-name")
except PackageNotFoundError:
__version__ = "0.0.0-dev" # Running from source without pip install
```
This reads whatever version `pip install` baked in. No manual syncing needed.
### 10.2. CI Stamps the Tag into `pyproject.toml`
Build scripts resolve the version (see section 3), then stamp it before building:
```bash
VERSION_CLEAN="${VERSION#v}" # "0.3.0" from "v0.3.0"
# Stamp into pyproject.toml so pip install bakes the correct version
sed -i "s/^version = .*/version = \"${VERSION_CLEAN}\"/" server/pyproject.toml
```
This happens in both `build-dist.sh` and `build-dist-windows.sh`, before `pip install` or wheel extraction.
### 10.3. Docker Version Stamping
Use a build arg so the version is stamped during the Docker build:
```dockerfile
ARG APP_VERSION=0.0.0
# Stamp before pip install (which bakes version into metadata)
RUN sed -i "s/^version = .*/version = \"${APP_VERSION}\"/" pyproject.toml \
&& pip install --no-cache-dir ".[notifications]"
```
In CI:
```yaml
docker build --build-arg APP_VERSION="${VERSION}" ./server
```
The `--label org.opencontainers.image.version` flag can also be set dynamically:
```yaml
docker build \
--build-arg APP_VERSION="${VERSION}" \
--label "org.opencontainers.image.version=${VERSION}" \
./server
```
### 10.4. Version Fallback Chain (Updated)
The build script fallback (section 3) should read from `pyproject.toml` instead of `__init__.py`:
```bash
# Fallback 4: Read from pyproject.toml (single source of truth)
if [ -z "$VERSION" ]; then
VERSION=$(grep -oP '^version\s*=\s*"\K[^"]+' server/pyproject.toml 2>/dev/null || echo "0.0.0")
fi
```
### 10.5. Version Flow Summary
```text
git tag v0.3.0
→ CI reads tag
→ sed stamps pyproject.toml with "0.3.0"
→ pip install bakes "0.3.0" into package metadata
→ importlib.metadata.version() reads "0.3.0" at runtime
→ UI shows "v0.3.0", update checker compares against it
```
## 11. In-App Auto-Update
For self-hosted apps distributed as installers or portable ZIPs, you can implement in-app update checking and one-click updates. The design separates the platform-specific hosting API (Gitea, GitHub) from the update logic via an abstraction layer.
### 11.1. Architecture
```text
UpdateService (background task)
├── ReleaseProvider (abstract)
│ ├── GiteaReleaseProvider — GET /api/v1/repos/{repo}/releases
│ └── GitHubReleaseProvider — GET api.github.com/repos/{owner}/{repo}/releases
├── VersionCheck — normalize semver tags, compare via packaging.version
└── InstallType — detect installer vs portable vs Docker vs dev
```
### 11.2. Release Provider Abstraction
The abstract interface has two methods:
```python
class ReleaseProvider(ABC):
@abstractmethod
async def get_releases(self, limit: int = 10) -> list[ReleaseInfo]: ...
@abstractmethod
def get_releases_page_url(self) -> str: ...
```
`ReleaseInfo` is a frozen dataclass with `tag`, `version`, `name`, `body`, `prerelease`, `published_at`, and `assets` (tuple of `AssetInfo` with `name`, `size`, `download_url`).
**Gitea implementation** hits `GET {base_url}/api/v1/repos/{repo}/releases?limit=N`. **GitHub** would hit `api.github.com/repos/{owner}/{repo}/releases`. The rest of the system only depends on the abstract interface — swapping providers is a config change.
### 11.3. Version Comparison
Gitea/GitHub tags like `v0.3.0-alpha.1` must be normalized to PEP 440 for comparison:
```python
import re
from packaging.version import Version
_PRE_PATTERN = re.compile(r"^(\d+\.\d+\.\d+)[-.]?(alpha|beta|rc)[.-]?(\d+)$", re.IGNORECASE)
_PRE_MAP = {"alpha": "a", "beta": "b", "rc": "rc"}
def normalize_version(raw: str) -> Version:
cleaned = raw.lstrip("v").strip()
m = _PRE_PATTERN.match(cleaned)
if m:
base, pre_label, pre_num = m.group(1), m.group(2).lower(), m.group(3)
cleaned = f"{base}{_PRE_MAP[pre_label]}{pre_num}"
return Version(cleaned)
```
Examples: `v0.3.0-alpha.1``0.3.0a1`, `v0.3.0-rc.3``0.3.0rc3`, `v1.0.0``1.0.0`.
### 11.4. Install Type Detection
Detect at startup by checking filesystem markers:
| Marker | Install type | Auto-update strategy |
|--------|-------------|---------------------|
| `uninstall.exe` in CWD | `installer` | Download `-setup.exe`, run `/S /D=<dir>` (silent NSIS reinstall) |
| `python/python.exe` in CWD (no uninstaller) | `portable` (Windows) | Download ZIP, extract, swap `app/` + `python/` via detached bat script |
| `venv/` + `run.sh` in CWD | `portable` (Linux) | Download tarball, extract, swap `app/` + `venv/` via detached shell script |
| `/.dockerenv` exists | `docker` | No auto-update — show "pull new image" instructions |
| None of above | `dev` | No auto-update — link to releases page |
### 11.5. Update Service (Background Task)
Follows the same pattern as other background services (e.g., auto-backup):
- **Settings** persisted in DB: `enabled`, `check_interval_hours`, `include_prerelease`
- **30s startup delay** to avoid slowing boot
- **60s debounce** on manual checks to prevent API spam
- **State machine**: `IDLE → CHECKING → UPDATE_AVAILABLE → DOWNLOADING → APPLYING`
- **WebSocket events**: `update_available` (triggers banner), `update_download_progress` (progress bar)
- **Dismissed version** persisted in DB (survives restarts)
### 11.6. Update Application Strategies
**NSIS installer (Windows):**
```python
# Launch installer silently, detached from server process
subprocess.Popen(
[str(exe_path), "/S", f"/D={install_dir}"],
creationflags=DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP,
)
# Shut down server — installer waits for python.exe to unlock, then installs
request_shutdown()
```
The NSIS installer's `.onInit` detects the locked `python.exe` and handles it. After install, the VBS launcher or startup shortcut restarts the app.
**Portable ZIP (Windows):**
Can't replace files while the server is running (DLL locks). Solution: write a `.bat` script that runs after shutdown:
```batch
@echo off
timeout /t 5 /nobreak >nul
rmdir /s /q "C:\path\app" 2>nul
move /y "C:\path\staging\LedGrab\app" "C:\path\app"
rmdir /s /q "C:\path\python" 2>nul
move /y "C:\path\staging\LedGrab\python" "C:\path\python"
rmdir /s /q "C:\path\staging" 2>nul
start "" wscript.exe "C:\path\scripts\start-hidden.vbs"
del /f /q "%~f0"
```
Launch it detached via `subprocess.Popen`, then shut down the server.
**Portable tarball (Linux):**
Same pattern but with a shell script:
```bash
#!/bin/bash
sleep 3
rm -rf "$APP_ROOT/app" && mv "$STAGING/LedGrab/app" "$APP_ROOT/app"
rm -rf "$APP_ROOT/venv" && mv "$STAGING/LedGrab/venv" "$APP_ROOT/venv"
rm -rf "$STAGING"
cd "$APP_ROOT" && exec ./run.sh
```
### 11.7. API Endpoints
| Method | Path | Purpose |
|--------|------|---------|
| `GET` | `/system/update/status` | Current state, available version, install type, progress |
| `POST` | `/system/update/check` | Trigger immediate check |
| `POST` | `/system/update/dismiss` | Dismiss notification for a version |
| `POST` | `/system/update/apply` | Download + install + restart |
| `GET/PUT` | `/system/update/settings` | Auto-check settings |
### 11.8. Frontend Integration
- **Version badge**: Add CSS class `has-update` to the version pill in the header — changes color + pulse animation, clickable to open settings
- **Banner**: Persistent dismissible bar below header with icon buttons (external link to releases, download to apply, X to dismiss). Stored in `localStorage` so dismissed state survives page refresh
- **Settings tab**: "Updates" tab showing current version, install type, status, check button, "Update Now" button (visible only when `can_auto_update` is true), download progress bar, release notes preview, auto-check toggle + interval + channel (stable/pre-release)
- **WebSocket events**: `update_available` triggers banner + badge, `update_download_progress` updates progress bar, `server_restarting` shows reconnect overlay
### 11.9. Key Design Decisions
1. **Provider abstraction** — Gitea today, GitHub/GitLab tomorrow. One interface, swap implementations via config.
2. **No data directory changes** — Updates replace `app/` and `python/` (or `venv/`) but **never touch `data/`**. User configuration survives every update.
3. **Detached post-update scripts** — The server can't replace its own files while running. A detached script waits for shutdown, does the swap, then restarts.
4. **Silent NSIS reinstall** — NSIS `/S` flag runs the full installer flow without UI, preserving the `data/` directory because the uninstaller section is never triggered during an upgrade.
5. **Asset matching by filename pattern** — e.g., `*-setup.exe` for installer, `*-win-x64.zip` for portable. Platform + install type determines the pattern.
6. **Pre-release channel** — Users opt in to alpha/beta/RC builds via settings. Default is stable only.
## 12. 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)
- [ ] Set up `importlib.metadata` version in `__init__.py` (section 10.1)
- [ ] Add `sed` version stamp step in build scripts (section 10.2)
- [ ] 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
- [ ] Add in-app update checker if self-hosted (section 11)
## 11. Local Build Testing (Windows)
## 13. Local Build Testing (Windows)
The CI builds on Linux, but you can build locally on Windows for faster iteration.
### 11.1. Prerequisites
### 13.1. Prerequisites
Install NSIS (for installer builds):
@@ -534,7 +769,7 @@ Install NSIS (for installer builds):
# Installs to: C:\Program Files (x86)\NSIS\makensis.exe
```
### 11.2. Build Steps
### 13.2. Build Steps
```bash
# 1. Build frontend
@@ -548,7 +783,7 @@ bash build-dist-windows.sh v1.0.0
# Output: build/MediaServer-v1.0.0-setup.exe
```
### 11.3. Common Issues
### 13.3. Common Issues
| Issue | Cause | Fix |
|-------|-------|-----|
@@ -559,8 +794,10 @@ bash build-dist-windows.sh v1.0.0
| `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/` |
| Audio visualizer returns all zeros / `numpy.fromstring` removed | `pip download` pulls numpy 2.x as a transitive dependency; `soundcard` uses `numpy.fromstring` which was removed in numpy 2.0 | Pin `numpy>=1.24.0,<2.0` in both `pyproject.toml` and `build-dist-windows.sh`. Also add a post-download cleanup step to remove numpy 2.x wheels pulled transitively: `for f in "$WHEEL_DIR"/numpy-2*; do rm "$f"; done` |
| Wrong numpy version despite pinning `<2.0` | The `pip download` fallback (no `--only-binary`) ignores platform constraints and pulls the host Python's numpy. Also, transitive deps from other packages (e.g. `soundcard`, `uvicorn`) can pull unconstrained numpy | Add `--python-version` to fallback command, use `--no-cache-dir`, and strip numpy 2.x wheels after the download loop |
### 11.4. Iterating on Installer Only
### 13.4. Iterating on Installer Only
If you only changed `installer.nsi` (not app code), skip the full rebuild:
@@ -572,11 +809,11 @@ If you only changed `installer.nsi` (not app code), skip the full rebuild:
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
## 14. Troubleshooting
### Running server blocks installation
See section 11.3. The `.onInit` function in section 6 shows how to detect a locked
See section 13.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