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:
+245
-8
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user