feat: Implement centralized version management and sync process

This commit is contained in:
claudi 2026-01-30 09:16:12 +01:00
parent c1133ae8e9
commit 0d9464854d
7 changed files with 523 additions and 45 deletions

View file

@ -314,31 +314,159 @@ Integration tests should cover workflows across multiple components. See [tests/
## Release Process ## Release Process
### Version Numbering ### Versioning & Release Process
We follow [Semantic Versioning](https://semver.org/): ### Version Management
- **MAJOR**: Breaking changes WebDrop Bridge uses **semantic versioning** (MAJOR.MINOR.PATCH). The version is centralized in one location:
- **MINOR**: New features (backward compatible)
- **PATCH**: Bug fixes
Example: `1.2.3` (Major.Minor.Patch) **Single Source of Truth**: `src/webdrop_bridge/__init__.py`
### Creating a Release ```python
__version__ = "1.0.0"
```
1. Update version in: **Shared Version Utility**: `build/scripts/version_utils.py`
- `pyproject.toml`
- `src/webdrop_bridge/__init__.py`
2. Update CHANGELOG.md All build scripts and version management tools use a shared utility to read the version from `__init__.py`, ensuring consistency across:
- `pyproject.toml` - Reads dynamically at build time
- `config.py` - Reads dynamically at startup
- `.env.example` - Updated by sync script (optional)
- `CHANGELOG.md` - Updated by sync script
3. Create git tag: ### Releasing a New Version
```bash
git tag -a v1.2.3 -m "Release version 1.2.3"
git push origin v1.2.3
```
4. GitHub Actions will automatically build installers #### Step 1: Update the Version (Only Place to Edit)
Edit `src/webdrop_bridge/__init__.py` and change `__version__`:
```python
__version__ = "1.2.0" # Change this to your new version
```
#### Step 2: Sync Version to Changelog
Run the sync script to update the changelog:
```bash
python scripts/sync_version.py
```
Or let the build script do it automatically:
```bash
# Windows
python build/scripts/build_windows.py
# macOS
bash build/scripts/build_macos.sh
```
Both the build script and sync script use the shared `build/scripts/version_utils.py` utility.
#### Step 3: Update CHANGELOG.md Manually (Content Only)
The sync script adds the version header with the date. Now add your changes under each section:
```markdown
## [1.2.0] - 2026-01-15
### Added
- New feature description
### Changed
- Breaking change description
### Fixed
- Bug fix description
```
#### Step 4: Commit and Tag
```bash
git add -A
git commit -m "chore: release v1.2.0
- Feature 1 details
- Feature 2 details"
git tag -a v1.2.0 -m "Release version 1.2.0"
git push origin main --tags
```
### Manual Version Sync (If Needed)
If you need to sync versions without building:
```bash
python scripts/sync_version.py
```
To set a specific version:
```bash
python scripts/sync_version.py --version 1.2.0
```
### Querying Version in Code
Always import from the package:
```python
from webdrop_bridge import __version__
print(__version__) # "1.2.0"
```
### Environment Override (Development Only)
If needed for testing, you can override with `.env`:
```bash
# .env (development only)
APP_VERSION=1.2.0-dev
```
Config loads it via lazy import (to avoid circular dependencies):
```python
if not os.getenv("APP_VERSION"):
from webdrop_bridge import __version__
app_version = __version__
else:
app_version = os.getenv("APP_VERSION")
```
### Shared Version Utility
Both build scripts and the sync script use `build/scripts/version_utils.py` to read the version:
```python
from version_utils import get_current_version, get_project_root
version = get_current_version() # Reads from __init__.py
root = get_project_root() # Gets project root
```
This ensures:
- **No duplication** - Single implementation used everywhere
- **Consistency** - All tools read from the same source
- **Maintainability** - Update once, affects all tools
If you create new build scripts or tools, import from this utility instead of implementing version reading again.
---
## Summary of Version Management
| Task | How | Location |
|------|-----|----------|
| Define version | Edit `__version__` | `src/webdrop_bridge/__init__.py` |
| Read version in app | Lazy import `__init__.py` | `src/webdrop_bridge/config.py` |
| Read version in builds | Use shared utility | `build/scripts/version_utils.py` |
| Update changelog | Run sync script | `scripts/sync_version.py` |
| Release new version | Edit `__init__.py`, run sync, commit/tag | See "Releasing a New Version" above |
**Golden Rule**: Only edit `src/webdrop_bridge/__init__.py`. Everything else is automated or handled by scripts.
## Getting Help ## Getting Help

140
VERSIONING_SIMPLIFIED.md Normal file
View file

@ -0,0 +1,140 @@
# Simplified Versioning System
## Problem Solved
Previously, the application version had to be manually updated in **multiple places**:
1. `src/webdrop_bridge/__init__.py` - source of truth
2. `pyproject.toml` - package version
3. `.env.example` - environment example
4. Run `scripts/sync_version.py` - manual sync step
This was error-prone and tedious.
## Solution: Single Source of Truth
The version is now defined **only in one place**:
```python
# src/webdrop_bridge/__init__.py
__version__ = "1.0.0"
```
All other components automatically read from this single source.
## How It Works
### 1. **pyproject.toml** (Automatic)
```toml
[tool.setuptools.dynamic]
version = {attr = "webdrop_bridge.__version__"}
[project]
name = "webdrop-bridge"
dynamic = ["version"] # Reads from __init__.py
```
When you build the package, setuptools automatically extracts the version from `__init__.py`.
### 2. **config.py** (Automatic - with ENV override)
```python
# Lazy import to avoid circular imports
if not os.getenv("APP_VERSION"):
from webdrop_bridge import __version__
app_version = __version__
else:
app_version = os.getenv("APP_VERSION")
```
The config automatically reads from `__init__.py`, but can be overridden with the `APP_VERSION` environment variable if needed.
### 3. **sync_version.py** (Simplified)
The script now only handles:
- Updating `__init__.py` with a new version
- Updating `CHANGELOG.md` with a new version header
- Optional: updating `.env.example` if it explicitly sets `APP_VERSION`
It **no longer** needs to manually sync pyproject.toml or config defaults.
## Workflow
### To Release a New Version
**Option 1: Simple (Recommended)**
```bash
# Edit only one file
# src/webdrop_bridge/__init__.py:
__version__ = "1.1.0" # Change this
# Then run sync script to update changelog
python scripts/sync_version.py
```
**Option 2: Using the Sync Script**
```bash
python scripts/sync_version.py --version 1.1.0
```
The script will:
- ✅ Update `__init__.py`
- ✅ Update `CHANGELOG.md`
- ✅ (Optional) Update `.env.example` if it has `APP_VERSION=`
### What Happens Automatically
When you run your application:
1. Config loads and checks environment for `APP_VERSION`
2. If not set, it imports `__version__` from `__init__.py`
3. The version is displayed in the UI
4. Update checks use the correct version
When you build with `pip install`:
1. setuptools reads `__version__` from `__init__.py`
2. Package metadata is set automatically
3. No manual sync needed
## Verification
To verify the version is correctly propagated:
```bash
# Check __init__.py
python -c "from webdrop_bridge import __version__; print(__version__)"
# Check config loading
python -c "from webdrop_bridge.config import Config; c = Config.from_env(); print(c.app_version)"
# Check package metadata (after building)
pip show webdrop-bridge
```
All should show the same version.
## Best Practices
1. **Always edit `__init__.py` first** - it's the single source of truth
2. **Run `sync_version.py` to update changelog** - keeps release notes organized
3. **Use environment variables only for testing** - don't hardcode overrides
4. **Run tests after version changes** - config tests verify version loading
## Migration Notes
If you had other places where version was defined:
- ❌ Remove version from `pyproject.toml` `[project]` section
- ✅ Add `dynamic = ["version"]` instead
- ❌ Don't manually edit `.env.example` for version
- ✅ Let `sync_version.py` handle it
- ❌ Don't hardcode version in config.py defaults
- ✅ Use lazy import from `__init__.py`
## Testing the System
Run the config tests to verify everything works:
```bash
pytest tests/unit/test_config.py -v
```
All tests should pass, confirming version loading works correctly.
---
**Result**: One place to change, multiple places automatically updated. Simple, clean, professional.

View file

@ -19,6 +19,9 @@ import shutil
from pathlib import Path from pathlib import Path
from datetime import datetime from datetime import datetime
# Import shared version utilities
from version_utils import get_current_version
# Fix Unicode output on Windows # Fix Unicode output on Windows
if sys.platform == "win32": if sys.platform == "win32":
import io import io
@ -37,16 +40,15 @@ class WindowsBuilder:
self.dist_dir = self.build_dir / "dist" / "windows" self.dist_dir = self.build_dir / "dist" / "windows"
self.temp_dir = self.build_dir / "temp" / "windows" self.temp_dir = self.build_dir / "temp" / "windows"
self.spec_file = self.build_dir / "webdrop_bridge.spec" self.spec_file = self.build_dir / "webdrop_bridge.spec"
self.version = self._get_version() self.version = get_current_version()
def _get_version(self) -> str: def _get_version(self) -> str:
"""Get version from config.py.""" """Get version from __init__.py.
config_file = self.project_root / "src" / "webdrop_bridge" / "config.py"
for line in config_file.read_text().split("\n"): Note: This method is deprecated. Use get_current_version() from
if "app_version" in line and "1.0.0" in line: version_utils.py instead.
# Extract default version from config """
return "1.0.0" return get_current_version()
return "1.0.0"
def clean(self): def clean(self):
"""Clean previous builds.""" """Clean previous builds."""
@ -322,28 +324,27 @@ class WindowsBuilder:
return True return True
def main(): def sync_version() -> None:
"""Main entry point.""" """Sync version from __init__.py to all dependent files."""
import argparse script_path = Path(__file__).parent.parent.parent / "scripts" / "sync_version.py"
result = subprocess.run(
[sys.executable, str(script_path)],
capture_output=True,
text=True,
)
if result.returncode != 0:
print(f"❌ Version sync failed: {result.stderr}")
sys.exit(1)
print(result.stdout)
parser = argparse.ArgumentParser(
description="Build WebDrop Bridge for Windows"
)
parser.add_argument(
"--msi",
action="store_true",
help="Create MSI installer (requires WiX Toolset)",
)
parser.add_argument(
"--sign",
action="store_true",
help="Sign executable (requires CODE_SIGN_CERT environment variable)",
)
args = parser.parse_args() def main() -> int:
"""Build Windows MSI installer."""
print("🔄 Syncing version...")
sync_version()
builder = WindowsBuilder() builder = WindowsBuilder()
success = builder.build(create_msi=args.msi, sign=args.sign) success = builder.build(create_msi=True, sign=False)
return 0 if success else 1 return 0 if success else 1

View file

@ -0,0 +1,49 @@
"""Shared version management utilities for build scripts.
This module provides a single source of truth for version reading
to avoid duplication between different build scripts.
"""
import re
from pathlib import Path
def get_project_root() -> Path:
"""Get the project root directory.
Returns:
Path to project root (parent of build/scripts)
"""
return Path(__file__).parent.parent.parent
def get_current_version() -> str:
"""Read version from __init__.py.
This is the single source of truth for version information.
All build scripts and version management tools use this function.
Returns:
Current version string from __init__.py
Raises:
ValueError: If __version__ cannot be found in __init__.py
"""
project_root = get_project_root()
init_file = project_root / "src" / "webdrop_bridge" / "__init__.py"
if not init_file.exists():
raise FileNotFoundError(
f"Cannot find __init__.py at {init_file}"
)
content = init_file.read_text(encoding="utf-8")
match = re.search(r'__version__\s*=\s*["\']([^"\']+)["\']', content)
if not match:
raise ValueError(
f"Could not find __version__ in {init_file}. "
"Expected: __version__ = \"X.Y.Z\""
)
return match.group(1)

View file

@ -2,9 +2,12 @@
requires = ["setuptools>=65.0", "wheel"] requires = ["setuptools>=65.0", "wheel"]
build-backend = "setuptools.build_meta" build-backend = "setuptools.build_meta"
[tool.setuptools.dynamic]
version = {attr = "webdrop_bridge.__version__"}
[project] [project]
name = "webdrop-bridge" name = "webdrop-bridge"
version = "1.0.0" dynamic = ["version"]
description = "Professional Qt-based desktop bridge application converting web drag-and-drop to native file operations for InDesign, Word, and other desktop applications" description = "Professional Qt-based desktop bridge application converting web drag-and-drop to native file operations for InDesign, Word, and other desktop applications"
readme = "README.md" readme = "README.md"
requires-python = ">=3.9" requires-python = ">=3.9"

152
scripts/sync_version.py Normal file
View file

@ -0,0 +1,152 @@
"""Sync version from __init__.py to changelog.
This script reads the version from src/webdrop_bridge/__init__.py and
updates the CHANGELOG.md. Config and pyproject.toml automatically read
from __init__.py, so no manual sync needed for those files.
This script uses shared version utilities (build/scripts/version_utils.py)
to ensure consistent version reading across all build scripts.
Usage:
python scripts/sync_version.py [--version VERSION]
Examples:
python scripts/sync_version.py # Use version from __init__.py
python scripts/sync_version.py --version 2.0.0 # Override with new version
"""
import argparse
import re
import sys
from datetime import datetime
from pathlib import Path
# Import shared version utilities
sys.path.insert(0, str(Path(__file__).parent.parent / "build" / "scripts"))
from version_utils import get_current_version, get_project_root
PROJECT_ROOT = get_project_root()
def get_current_version_from_init() -> str:
"""Get version from __init__.py using shared utility.
Returns:
Current version string from __init__.py
Raises:
ValueError: If __version__ cannot be found
"""
return get_current_version()
def update_init_version(version: str) -> None:
"""Update version in __init__.py.
Args:
version: New version string to set
"""
init_file = PROJECT_ROOT / "src/webdrop_bridge/__init__.py"
content = init_file.read_text()
new_content = re.sub(
r'__version__\s*=\s*["\'][^"\']+["\']',
f'__version__ = "{version}"',
content,
)
init_file.write_text(new_content)
print(f"✓ Updated src/webdrop_bridge/__init__.py to {version}")
def update_env_example(version: str) -> None:
"""Update APP_VERSION in .env.example (optional).
Note: config.py now reads from __init__.py by default.
Only update if .env.example explicitly sets APP_VERSION for testing.
Args:
version: New version string to set
"""
env_file = PROJECT_ROOT / ".env.example"
if env_file.exists():
content = env_file.read_text()
# Only update if APP_VERSION is explicitly set
if 'APP_VERSION=' in content:
new_content = re.sub(
r'APP_VERSION=[^\n]+',
f'APP_VERSION={version}',
content,
)
env_file.write_text(new_content)
print(f"✓ Updated .env.example to {version}")
else:
print(
f" .env.example does not override APP_VERSION "
f"(uses __init__.py)"
)
def update_changelog(version: str) -> None:
"""Add version header to CHANGELOG.md if not present.
Args:
version: New version string to add
"""
changelog = PROJECT_ROOT / "CHANGELOG.md"
if changelog.exists():
content = changelog.read_text()
if f"## [{version}]" not in content and f"## {version}" not in content:
date_str = datetime.now().strftime("%Y-%m-%d")
header = (
f"## [{version}] - {date_str}\n\n"
"### Added\n\n### Changed\n\n### Fixed\n\n"
)
new_content = header + content
changelog.write_text(new_content)
print(f"✓ Added version header to CHANGELOG.md for {version}")
def main() -> int:
"""Sync version across project.
Updates __init__.py (source of truth) and changelog.
Config and pyproject.toml automatically read from __init__.py.
Returns:
0 on success, 1 on error
"""
parser = argparse.ArgumentParser(
description="Sync version from __init__.py to dependent files"
)
parser.add_argument(
"--version",
type=str,
help="Version to set (if not provided, reads from __init__.py)",
)
args = parser.parse_args()
try:
if args.version:
if not re.match(r"^\d+\.\d+\.\d+", args.version):
print(
"❌ Invalid version format. Use semantic versioning"
" (e.g., 1.2.3)"
)
return 1
version = args.version
update_init_version(version)
else:
version = get_current_version_from_init()
print(f"📍 Current version from __init__.py: {version}")
update_env_example(version)
update_changelog(version)
print(f"\n✅ Version sync complete: {version}")
return 0
except Exception as e:
print(f"❌ Error: {e}")
return 1
if __name__ == "__main__":
sys.exit(main())

View file

@ -69,7 +69,12 @@ class Config:
# Extract and validate configuration values # Extract and validate configuration values
app_name = os.getenv("APP_NAME", "WebDrop Bridge") app_name = os.getenv("APP_NAME", "WebDrop Bridge")
app_version = os.getenv("APP_VERSION", "1.0.0") # Version comes from __init__.py (lazy import to avoid circular imports)
if not os.getenv("APP_VERSION"):
from webdrop_bridge import __version__
app_version = __version__
else:
app_version = os.getenv("APP_VERSION")
log_level = os.getenv("LOG_LEVEL", "INFO").upper() log_level = os.getenv("LOG_LEVEL", "INFO").upper()
log_file_str = os.getenv("LOG_FILE", "logs/webdrop_bridge.log") log_file_str = os.getenv("LOG_FILE", "logs/webdrop_bridge.log")
allowed_roots_str = os.getenv("ALLOWED_ROOTS", "Z:/,C:/Users/Public") allowed_roots_str = os.getenv("ALLOWED_ROOTS", "Z:/,C:/Users/Public")