feat: Implement centralized version management and sync process
This commit is contained in:
parent
c1133ae8e9
commit
0d9464854d
7 changed files with 523 additions and 45 deletions
162
CONTRIBUTING.md
162
CONTRIBUTING.md
|
|
@ -314,31 +314,159 @@ Integration tests should cover workflows across multiple components. See [tests/
|
|||
|
||||
## Release Process
|
||||
|
||||
### Version Numbering
|
||||
### Versioning & Release Process
|
||||
|
||||
We follow [Semantic Versioning](https://semver.org/):
|
||||
### Version Management
|
||||
|
||||
- **MAJOR**: Breaking changes
|
||||
- **MINOR**: New features (backward compatible)
|
||||
- **PATCH**: Bug fixes
|
||||
WebDrop Bridge uses **semantic versioning** (MAJOR.MINOR.PATCH). The version is centralized in one location:
|
||||
|
||||
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:
|
||||
- `pyproject.toml`
|
||||
- `src/webdrop_bridge/__init__.py`
|
||||
**Shared Version Utility**: `build/scripts/version_utils.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:
|
||||
```bash
|
||||
git tag -a v1.2.3 -m "Release version 1.2.3"
|
||||
git push origin v1.2.3
|
||||
```
|
||||
### Releasing a New Version
|
||||
|
||||
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
|
||||
|
||||
|
|
|
|||
140
VERSIONING_SIMPLIFIED.md
Normal file
140
VERSIONING_SIMPLIFIED.md
Normal 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.
|
||||
|
|
@ -19,6 +19,9 @@ import shutil
|
|||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
|
||||
# Import shared version utilities
|
||||
from version_utils import get_current_version
|
||||
|
||||
# Fix Unicode output on Windows
|
||||
if sys.platform == "win32":
|
||||
import io
|
||||
|
|
@ -37,16 +40,15 @@ class WindowsBuilder:
|
|||
self.dist_dir = self.build_dir / "dist" / "windows"
|
||||
self.temp_dir = self.build_dir / "temp" / "windows"
|
||||
self.spec_file = self.build_dir / "webdrop_bridge.spec"
|
||||
self.version = self._get_version()
|
||||
self.version = get_current_version()
|
||||
|
||||
def _get_version(self) -> str:
|
||||
"""Get version from config.py."""
|
||||
config_file = self.project_root / "src" / "webdrop_bridge" / "config.py"
|
||||
for line in config_file.read_text().split("\n"):
|
||||
if "app_version" in line and "1.0.0" in line:
|
||||
# Extract default version from config
|
||||
return "1.0.0"
|
||||
return "1.0.0"
|
||||
"""Get version from __init__.py.
|
||||
|
||||
Note: This method is deprecated. Use get_current_version() from
|
||||
version_utils.py instead.
|
||||
"""
|
||||
return get_current_version()
|
||||
|
||||
def clean(self):
|
||||
"""Clean previous builds."""
|
||||
|
|
@ -322,28 +324,27 @@ class WindowsBuilder:
|
|||
return True
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point."""
|
||||
import argparse
|
||||
def sync_version() -> None:
|
||||
"""Sync version from __init__.py to all dependent files."""
|
||||
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()
|
||||
success = builder.build(create_msi=args.msi, sign=args.sign)
|
||||
success = builder.build(create_msi=True, sign=False)
|
||||
|
||||
return 0 if success else 1
|
||||
|
||||
|
|
|
|||
49
build/scripts/version_utils.py
Normal file
49
build/scripts/version_utils.py
Normal 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)
|
||||
|
|
@ -2,9 +2,12 @@
|
|||
requires = ["setuptools>=65.0", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[tool.setuptools.dynamic]
|
||||
version = {attr = "webdrop_bridge.__version__"}
|
||||
|
||||
[project]
|
||||
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"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.9"
|
||||
|
|
|
|||
152
scripts/sync_version.py
Normal file
152
scripts/sync_version.py
Normal 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())
|
||||
|
|
@ -69,7 +69,12 @@ class Config:
|
|||
|
||||
# Extract and validate configuration values
|
||||
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_file_str = os.getenv("LOG_FILE", "logs/webdrop_bridge.log")
|
||||
allowed_roots_str = os.getenv("ALLOWED_ROOTS", "Z:/,C:/Users/Public")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue