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) | | Secrets | `${{ secrets.NAME }}` | `${{ secrets.NAME }}` (same) |
| Docker Buildx | Available | May not work (runner networking) | | 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/test.yml` — lint + test on push/PR
- [ ] Create `.gitea/workflows/release.yml` — build + release on `v*` tag - [ ] Create `.gitea/workflows/release.yml` — build + release on `v*` tag
- [ ] Add `GITEA_TOKEN` secret to repository - [ ] Add `GITEA_TOKEN` secret to repository
- [ ] Set up version detection in build scripts (tag → env → source) - [ ] 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.sh` for Linux (venv + tarball)
- [ ] Create `build-dist-windows.sh` for Windows (embedded Python + ZIP) - [ ] Create `build-dist-windows.sh` for Windows (embedded Python + ZIP)
- [ ] Create `installer.nsi` for Windows installer (optional) - [ ] Create `installer.nsi` for Windows installer (optional)
- [ ] Create `Dockerfile` for Docker builds (optional) - [ ] Create `Dockerfile` for Docker builds (optional)
- [ ] Configure `pyproject.toml` with `[tool.ruff]` and `[tool.pytest]` - [ ] Configure `pyproject.toml` with `[tool.ruff]` and `[tool.pytest]`
- [ ] Set up `.pre-commit-config.yaml` with ruff + black - [ ] 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. 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): Install NSIS (for installer builds):
@@ -534,7 +769,7 @@ Install NSIS (for installer builds):
# Installs to: C:\Program Files (x86)\NSIS\makensis.exe # Installs to: C:\Program Files (x86)\NSIS\makensis.exe
``` ```
### 11.2. Build Steps ### 13.2. Build Steps
```bash ```bash
# 1. Build frontend # 1. Build frontend
@@ -548,7 +783,7 @@ bash build-dist-windows.sh v1.0.0
# Output: build/MediaServer-v1.0.0-setup.exe # Output: build/MediaServer-v1.0.0-setup.exe
``` ```
### 11.3. Common Issues ### 13.3. Common Issues
| Issue | Cause | Fix | | 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` | | `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 `""` | | 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/` | | `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: 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 — 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. the `dist/` directory is a snapshot and won't pick up source changes automatically.
## 12. Troubleshooting ## 14. Troubleshooting
### Running server blocks installation ### 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. `python.exe` and prompt the user before proceeding.
### Release already exists for tag ### Release already exists for tag