From a5341ee2842e3dbf1132cdb615d3879ed3c64c24 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Nov 2025 20:04:15 +0000 Subject: [PATCH] Add automated version bumping and publishing workflow Fixes the PyPI publish error by automating version management: - Add scripts/bump_version.py: Bump patch/minor/major versions - Add scripts/publish.py: Automated publishing workflow - Add scripts/check_pypi.py: Check if version exists on PyPI - Add Makefile: Convenient commands for dev and publishing - Add PUBLISHING.md: Comprehensive publishing documentation - Update .github/workflows/python-publish.yml: Add version check - Update CLAUDE.md: Document new publishing commands Now running 'make publish-patch' will: 1. Check git status is clean 2. Verify version doesn't exist on PyPI 3. Bump version in pyproject.toml 4. Commit and tag the change 5. Push to GitHub 6. Provide instructions for creating the release This prevents the "File already exists" error by ensuring the version is always bumped before publishing. --- .github/workflows/python-publish.yml | 9 ++ CLAUDE.md | 18 +++ Makefile | 83 ++++++++++ PUBLISHING.md | 226 +++++++++++++++++++++++++++ scripts/bump_version.py | 140 +++++++++++++++++ scripts/check_pypi.py | 44 ++++++ scripts/publish.py | 170 ++++++++++++++++++++ 7 files changed, 690 insertions(+) create mode 100644 Makefile create mode 100644 PUBLISHING.md create mode 100755 scripts/bump_version.py create mode 100755 scripts/check_pypi.py create mode 100755 scripts/publish.py diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml index 52339c4..683f280 100644 --- a/.github/workflows/python-publish.yml +++ b/.github/workflows/python-publish.yml @@ -27,6 +27,15 @@ jobs: uses: astral-sh/setup-uv@85856786d1ce8acfbcc2f13a5f3fbd6b938f9f41 # v7.1.2 with: enable-cache: true + - name: Check version not on PyPI + run: | + VERSION=$(grep '^version = ' pyproject.toml | sed 's/version = "\(.*\)"/\1/') + echo "Checking version: $VERSION" + if curl -s "https://pypi.org/pypi/pyosmo/$VERSION/json" | grep -q "\"$VERSION\""; then + echo "Error: Version $VERSION already exists on PyPI!" + exit 1 + fi + echo "Version $VERSION is available for publishing" - name: Build package run: uv build - name: Publish package diff --git a/CLAUDE.md b/CLAUDE.md index f182384..fe650fd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -64,6 +64,24 @@ python -m build pyosmo examples/calculator_example.py --algorithm weighted --test-len 100 ``` +### Publishing (NEW) +```bash +# Automated publishing workflow +make publish-patch # Bump patch version (0.2.2 -> 0.2.3) and publish +make publish-minor # Bump minor version (0.2.2 -> 0.3.0) and publish +make publish-major # Bump major version (0.2.2 -> 1.0.0) and publish + +# Just bump version (no publish) +make version-patch +make version-minor +make version-major + +# Check if version exists on PyPI +make check-pypi +``` + +See [PUBLISHING.md](PUBLISHING.md) for detailed publishing instructions and troubleshooting. + ## Architecture ### Core Components diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..2b27718 --- /dev/null +++ b/Makefile @@ -0,0 +1,83 @@ +.PHONY: help test lint format version-patch version-minor version-major publish-patch publish-minor publish-major build check-pypi + +help: + @echo "PyOsmo Development Commands" + @echo "" + @echo "Testing:" + @echo " make test Run all tests" + @echo " make test-cov Run tests with coverage" + @echo "" + @echo "Linting & Formatting:" + @echo " make lint Run ruff linter" + @echo " make format Format code with ruff" + @echo " make typecheck Run mypy type checker" + @echo "" + @echo "Version Management:" + @echo " make version-patch Bump patch version (0.2.2 -> 0.2.3)" + @echo " make version-minor Bump minor version (0.2.2 -> 0.3.0)" + @echo " make version-major Bump major version (0.2.2 -> 1.0.0)" + @echo " make check-pypi Check if current version exists on PyPI" + @echo "" + @echo "Publishing:" + @echo " make publish-patch Bump patch version and publish" + @echo " make publish-minor Bump minor version and publish" + @echo " make publish-major Bump major version and publish" + @echo "" + @echo "Building:" + @echo " make build Build distribution packages" + @echo " make clean Clean build artifacts" + +# Testing +test: + pytest pyosmo/tests/ + +test-cov: + pytest pyosmo/tests/ --cov=pyosmo + +# Linting & Formatting +lint: + ruff check pyosmo/ + +lint-fix: + ruff check pyosmo/ --fix + +format: + ruff format pyosmo/ + +format-check: + ruff format --check pyosmo/ + +typecheck: + mypy pyosmo/ + +# Version Management +version-patch: + python scripts/bump_version.py patch + +version-minor: + python scripts/bump_version.py minor + +version-major: + python scripts/bump_version.py major + +check-pypi: + @python scripts/check_pypi.py + +# Publishing (automated workflow) +publish-patch: + python scripts/publish.py patch + +publish-minor: + python scripts/publish.py minor + +publish-major: + python scripts/publish.py major + +# Building +build: + python -m build + +clean: + rm -rf build/ dist/ *.egg-info + find . -type d -name __pycache__ -exec rm -rf {} + + find . -type f -name '*.pyc' -delete diff --git a/PUBLISHING.md b/PUBLISHING.md new file mode 100644 index 0000000..4afed60 --- /dev/null +++ b/PUBLISHING.md @@ -0,0 +1,226 @@ +# Publishing Guide + +This guide explains how to publish new versions of PyOsmo to PyPI. + +## Quick Start + +The easiest way to publish a new version is to use the automated scripts: + +```bash +# Bump patch version (0.2.2 -> 0.2.3) and publish +make publish-patch + +# Bump minor version (0.2.2 -> 0.3.0) and publish +make publish-minor + +# Bump major version (0.2.2 -> 1.0.0) and publish +make publish-major +``` + +## What the Automated Script Does + +The `scripts/publish.py` script automates the entire publishing workflow: + +1. **Checks git status** - Ensures working directory is clean +2. **Checks PyPI** - Verifies the current and new versions don't already exist +3. **Bumps version** - Updates version in `pyproject.toml` +4. **Commits changes** - Creates a commit with the version bump +5. **Creates git tag** - Tags the commit with `v{version}` +6. **Pushes to GitHub** - Pushes both commit and tag +7. **Provides next steps** - Shows link to create GitHub release + +## Manual Version Bumping + +If you just want to bump the version without publishing: + +```bash +# Using make +make version-patch # 0.2.2 -> 0.2.3 +make version-minor # 0.2.2 -> 0.3.0 +make version-major # 0.2.2 -> 1.0.0 + +# Using the script directly +python scripts/bump_version.py patch +python scripts/bump_version.py minor +python scripts/bump_version.py major + +# Set specific version +python scripts/bump_version.py --version 1.0.0 + +# Dry run (see what would change) +python scripts/bump_version.py --dry-run patch +``` + +## Manual Publishing Workflow + +If you prefer to do things manually: + +1. **Check current version on PyPI:** + ```bash + make check-pypi + ``` + +2. **Bump version:** + ```bash + python scripts/bump_version.py patch + ``` + +3. **Review changes:** + ```bash + git diff pyproject.toml + ``` + +4. **Commit and tag:** + ```bash + git add pyproject.toml + git commit -m "Bump version to 0.2.3" + git tag v0.2.3 + ``` + +5. **Push to GitHub:** + ```bash + git push origin main # or your branch + git push origin v0.2.3 + ``` + +6. **Create GitHub Release:** + - Go to https://github.com/osmo-tool/pyosmo/releases/new + - Select the tag you just created (e.g., `v0.2.3`) + - Click "Generate release notes" + - Review and edit the release notes if needed + - Click "Publish release" + +7. **Automatic PyPI Upload:** + - The GitHub Action workflow (`.github/workflows/python-publish.yml`) will automatically: + - Check that the version doesn't exist on PyPI + - Build the distribution packages + - Upload to PyPI using the configured API token + +## Troubleshooting + +### Error: Version already exists on PyPI + +If you see this error: +``` +HTTPError: 400 Bad Request from https://upload.pypi.org/legacy/ +File already exists ('pyosmo-0.2.2-py3-none-any.whl'...) +``` + +**Solution:** You need to bump the version before publishing: +```bash +make publish-patch # This will automatically bump and publish +``` + +Or manually: +```bash +python scripts/bump_version.py patch +``` + +### Error: Working directory is not clean + +The automated script requires a clean git working directory. + +**Solution:** Commit or stash your changes first: +```bash +git status +git add . +git commit -m "Your commit message" +# Then try publishing again +make publish-patch +``` + +### Version Check Fails in GitHub Actions + +If the GitHub Action fails with "Version already exists on PyPI", it means: +- You created a GitHub release without bumping the version first +- The version in `pyproject.toml` was already published + +**Solution:** +1. Delete the GitHub release and tag +2. Bump the version using the scripts +3. Create a new release with the new version + +### Skipping Pre-Publish Checks + +If you need to skip the git status and PyPI checks (not recommended): +```bash +python scripts/publish.py patch --skip-checks +``` + +## Version Numbering Guidelines + +Follow [Semantic Versioning (SemVer)](https://semver.org/): + +- **MAJOR** version (1.0.0 -> 2.0.0): Breaking changes / incompatible API changes +- **MINOR** version (0.2.0 -> 0.3.0): New features, backward compatible +- **PATCH** version (0.2.2 -> 0.2.3): Bug fixes, backward compatible + +### Examples + +- Bug fix: `make publish-patch` +- New feature: `make publish-minor` +- Breaking change: `make publish-major` + +## CI/CD Workflow + +The publishing workflow uses GitHub Actions: + +1. **Trigger**: Creating a GitHub release +2. **Actions performed**: + - Checkout code + - Set up Python 3.11 + - Install `uv` for fast builds + - **Check version doesn't exist on PyPI** ⭐ (prevents duplicate uploads) + - Build distribution packages (wheel + source) + - Upload to PyPI using API token + +**Required Secret**: `PYPI_API_TOKEN` must be configured in GitHub repository secrets. + +## First-Time Setup + +If you're setting up publishing for the first time: + +1. **Create PyPI API token:** + - Go to https://pypi.org/manage/account/token/ + - Create a new API token scoped to the `pyosmo` project + - Copy the token (it starts with `pypi-`) + +2. **Add token to GitHub:** + - Go to repository Settings > Secrets and variables > Actions + - Create new secret named `PYPI_API_TOKEN` + - Paste the PyPI token + +3. **Test publishing:** + - Consider testing on TestPyPI first + - Or publish a patch version bump as a test + +## Best Practices + +1. **Always bump version before creating a release** - Use the automated scripts +2. **Test thoroughly before publishing** - Run `make test` and `make lint` +3. **Use semantic versioning** - Follow the MAJOR.MINOR.PATCH convention +4. **Write good release notes** - Use GitHub's "Generate release notes" feature +5. **Check PyPI after publishing** - Verify the new version appears at https://pypi.org/project/pyosmo/ + +## Useful Commands + +```bash +# Development +make test # Run tests +make lint # Check code quality +make format # Format code + +# Version management +make check-pypi # Check if current version exists on PyPI +make version-patch # Bump patch version only (no publish) + +# Publishing +make publish-patch # Complete automated workflow +``` + +## Additional Resources + +- [PyPI Help](https://pypi.org/help/) +- [Semantic Versioning](https://semver.org/) +- [Python Packaging Guide](https://packaging.python.org/) +- [GitHub Actions for Python](https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python) diff --git a/scripts/bump_version.py b/scripts/bump_version.py new file mode 100755 index 0000000..bffa253 --- /dev/null +++ b/scripts/bump_version.py @@ -0,0 +1,140 @@ +#!/usr/bin/env python3 +""" +Version bumping script for PyOsmo. + +Usage: + python scripts/bump_version.py patch # 0.2.2 -> 0.2.3 + python scripts/bump_version.py minor # 0.2.2 -> 0.3.0 + python scripts/bump_version.py major # 0.2.2 -> 1.0.0 + python scripts/bump_version.py 0.3.0 # Set specific version +""" + +import argparse +import re +import sys +from pathlib import Path + + +def get_current_version(pyproject_path: Path) -> str: + """Extract current version from pyproject.toml.""" + content = pyproject_path.read_text() + match = re.search(r'^version\s*=\s*["\']([^"\']+)["\']', content, re.MULTILINE) + if not match: + raise ValueError('Could not find version in pyproject.toml') + return match.group(1) + + +def parse_version(version: str) -> tuple[int, int, int]: + """Parse version string into (major, minor, patch) tuple.""" + match = re.match(r'^(\d+)\.(\d+)\.(\d+)$', version) + if not match: + raise ValueError(f'Invalid version format: {version}') + return int(match.group(1)), int(match.group(2)), int(match.group(3)) + + +def bump_version(current: str, bump_type: str) -> str: + """Bump version according to bump_type (patch/minor/major).""" + major, minor, patch = parse_version(current) + + if bump_type == 'patch': + patch += 1 + elif bump_type == 'minor': + minor += 1 + patch = 0 + elif bump_type == 'major': + major += 1 + minor = 0 + patch = 0 + else: + # Assume it's a specific version string + parse_version(bump_type) # Validate format + return bump_type + + return f'{major}.{minor}.{patch}' + + +def update_pyproject(pyproject_path: Path, old_version: str, new_version: str) -> None: + """Update version in pyproject.toml.""" + content = pyproject_path.read_text() + + # Replace version line + old_line = f'version = "{old_version}"' + new_line = f'version = "{new_version}"' + + if old_line not in content: + # Try single quotes + old_line = f"version = '{old_version}'" + new_line = f"version = '{new_version}'" + + if old_line not in content: + raise ValueError(f'Could not find version line in pyproject.toml: {old_line}') + + new_content = content.replace(old_line, new_line) + pyproject_path.write_text(new_content) + + +def main() -> int: + parser = argparse.ArgumentParser(description='Bump version in pyproject.toml') + parser.add_argument( + 'bump_type', + choices=['patch', 'minor', 'major'], + nargs='?', + help='Version component to bump, or specific version number', + ) + parser.add_argument( + '--version', + help='Set specific version number (e.g., 0.3.0)', + ) + parser.add_argument( + '--dry-run', + action='store_true', + help='Show what would be changed without making changes', + ) + + args = parser.parse_args() + + # Determine what version change to make + if args.version: + bump_type = args.version + elif args.bump_type: + bump_type = args.bump_type + else: + parser.print_help() + return 1 + + # Find pyproject.toml + repo_root = Path(__file__).parent.parent + pyproject_path = repo_root / 'pyproject.toml' + + if not pyproject_path.exists(): + print(f'Error: pyproject.toml not found at {pyproject_path}', file=sys.stderr) + return 1 + + try: + current_version = get_current_version(pyproject_path) + new_version = bump_version(current_version, bump_type) + + print(f'Current version: {current_version}') + print(f'New version: {new_version}') + + if args.dry_run: + print('\n[DRY RUN] No changes made.') + return 0 + + update_pyproject(pyproject_path, current_version, new_version) + print(f'\n✓ Successfully updated pyproject.toml to version {new_version}') + print('\nNext steps:') + print(f' 1. Review changes: git diff pyproject.toml') + print(f' 2. Commit: git add pyproject.toml && git commit -m "Bump version to {new_version}"') + print(f' 3. Tag: git tag v{new_version} && git push origin v{new_version}') + print(f' 4. Create GitHub release from tag v{new_version}') + + return 0 + + except Exception as e: + print(f'Error: {e}', file=sys.stderr) + return 1 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/scripts/check_pypi.py b/scripts/check_pypi.py new file mode 100755 index 0000000..bfeb0bf --- /dev/null +++ b/scripts/check_pypi.py @@ -0,0 +1,44 @@ +#!/usr/bin/env python3 +"""Check if the current version exists on PyPI.""" + +import re +import sys +import urllib.error +import urllib.request +from pathlib import Path + + +def main() -> int: + # Read version from pyproject.toml + pyproject = Path(__file__).parent.parent / 'pyproject.toml' + content = pyproject.read_text() + + match = re.search(r'version\s*=\s*["\']([^"\']+)["\']', content) + if not match: + print('Error: Could not find version in pyproject.toml', file=sys.stderr) + return 1 + + version = match.group(1) + print(f'Current version: {version}') + + # Check if version exists on PyPI + url = f'https://pypi.org/pypi/pyosmo/{version}/json' + try: + with urllib.request.urlopen(url) as response: + if response.status == 200: + print(f'❌ Version {version} already exists on PyPI') + print(f' URL: https://pypi.org/project/pyosmo/{version}/') + return 1 + except urllib.error.HTTPError as e: + if e.code == 404: + print(f'✓ Version {version} is available on PyPI') + return 0 + else: + print(f'Error checking PyPI: {e}', file=sys.stderr) + return 1 + + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/scripts/publish.py b/scripts/publish.py new file mode 100755 index 0000000..0291305 --- /dev/null +++ b/scripts/publish.py @@ -0,0 +1,170 @@ +#!/usr/bin/env python3 +""" +Automated publishing script for PyOsmo. + +This script automates the entire publishing workflow: +1. Checks if version already exists on PyPI +2. Bumps version if needed +3. Commits and tags the new version +4. Pushes to GitHub +5. Creates a GitHub release (which triggers automatic PyPI upload) + +Usage: + python scripts/publish.py patch # Bump patch version and publish + python scripts/publish.py minor # Bump minor version and publish + python scripts/publish.py major # Bump major version and publish +""" + +import argparse +import json +import subprocess +import sys +import urllib.request +from pathlib import Path + + +def run_command(cmd: list[str], check: bool = True, capture: bool = False) -> subprocess.CompletedProcess: + """Run a shell command.""" + print(f'Running: {" ".join(cmd)}') + if capture: + return subprocess.run(cmd, check=check, capture_output=True, text=True) + return subprocess.run(cmd, check=check) + + +def get_current_version() -> str: + """Get current version from pyproject.toml.""" + result = subprocess.run( + ['python', 'scripts/bump_version.py', '--dry-run', 'patch'], + capture_output=True, + text=True, + check=True, + ) + # Parse output to get current version + for line in result.stdout.split('\n'): + if line.startswith('Current version:'): + return line.split(':')[1].strip() + raise ValueError('Could not determine current version') + + +def check_version_on_pypi(package_name: str, version: str) -> bool: + """Check if version exists on PyPI.""" + url = f'https://pypi.org/pypi/{package_name}/json' + try: + with urllib.request.urlopen(url) as response: + data = json.loads(response.read()) + versions = data.get('releases', {}).keys() + return version in versions + except urllib.error.HTTPError as e: + if e.code == 404: + # Package doesn't exist yet + return False + raise + + +def main() -> int: + parser = argparse.ArgumentParser(description='Automated PyOsmo publishing') + parser.add_argument( + 'bump_type', + choices=['patch', 'minor', 'major'], + help='Version component to bump', + ) + parser.add_argument( + '--skip-checks', + action='store_true', + help='Skip git status and PyPI version checks', + ) + parser.add_argument( + '--dry-run', + action='store_true', + help='Show what would happen without making changes', + ) + + args = parser.parse_args() + + try: + # Check git status + if not args.skip_checks: + result = run_command(['git', 'status', '--porcelain'], capture=True) + if result.stdout.strip() and not args.dry_run: + print('\nError: Working directory is not clean. Please commit or stash changes first.') + print(result.stdout) + return 1 + + # Get current version + current_version = get_current_version() + print(f'\nCurrent version: {current_version}') + + # Check if current version exists on PyPI + if not args.skip_checks: + print('Checking PyPI for existing version...') + if check_version_on_pypi('pyosmo', current_version): + print(f'⚠ Version {current_version} already exists on PyPI') + else: + print(f'✓ Version {current_version} not found on PyPI') + + # Bump version + print(f'\nBumping {args.bump_type} version...') + bump_cmd = ['python', 'scripts/bump_version.py', args.bump_type] + if args.dry_run: + bump_cmd.append('--dry-run') + + result = run_command(bump_cmd, capture=True) + print(result.stdout) + + # Extract new version from output + new_version = None + for line in result.stdout.split('\n'): + if line.startswith('New version:'): + new_version = line.split(':')[1].strip() + break + + if not new_version: + print('Error: Could not determine new version') + return 1 + + if args.dry_run: + print('\n[DRY RUN] Stopping here. No changes made.') + return 0 + + # Check if new version already exists on PyPI + if not args.skip_checks: + print(f'\nChecking if version {new_version} exists on PyPI...') + if check_version_on_pypi('pyosmo', new_version): + print(f'Error: Version {new_version} already exists on PyPI!') + print('You may need to bump a higher version component (minor or major).') + return 1 + else: + print(f'✓ Version {new_version} is available on PyPI') + + # Git operations + print('\nCommitting version bump...') + run_command(['git', 'add', 'pyproject.toml']) + run_command(['git', 'commit', '-m', f'Bump version to {new_version}']) + + print(f'\nCreating git tag v{new_version}...') + run_command(['git', 'tag', '-a', f'v{new_version}', '-m', f'Release version {new_version}']) + + print('\nPushing to GitHub...') + run_command(['git', 'push', 'origin', 'HEAD']) + run_command(['git', 'push', 'origin', f'v{new_version}']) + + print(f'\n✓ Successfully prepared release {new_version}!') + print('\nNext steps:') + print(f' 1. Go to GitHub: https://github.com/osmo-tool/pyosmo/releases/new') + print(f' 2. Select tag: v{new_version}') + print(f' 3. Click "Generate release notes"') + print(f' 4. Click "Publish release"') + print('\nThe GitHub Action will automatically publish to PyPI when the release is created.') + + return 0 + + except subprocess.CalledProcessError as e: + print(f'\nError: Command failed: {e}', file=sys.stderr) + return 1 + except Exception as e: + print(f'\nError: {e}', file=sys.stderr) + return 1 + + +if __name__ == '__main__': + sys.exit(main())