From ea4aca5850bea88e33bb8efe951339b02cb5f3bc Mon Sep 17 00:00:00 2001 From: Filip Strand Date: Sat, 14 Jun 2025 11:18:27 +0200 Subject: [PATCH] Update release script (#211) --- .github/workflows/release.yml | 16 +- scripts/generate_release_notes.py | 192 +++++++++++++++++ scripts/release.py | 329 ++++++++++++++++++++---------- 3 files changed, 426 insertions(+), 111 deletions(-) create mode 100644 scripts/generate_release_notes.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e30d685..89a1a51 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,8 +3,8 @@ name: Create Release and Publish to PyPI on: workflow_dispatch: inputs: - version: - description: 'Release version (e.g., 0.8.1)' + confirm: + description: 'Type "publish" to confirm release' required: true type: string @@ -16,6 +16,13 @@ jobs: - name: Checkout code uses: actions/checkout@v4 + - name: Validate confirmation + run: | + if [ "${{ github.event.inputs.confirm }}" != "publish" ]; then + echo "โŒ Must type 'publish' to confirm release" + exit 1 + fi + - name: Set up Python uses: actions/setup-python@v4 with: @@ -24,13 +31,12 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - python -m pip install requests + python -m pip install requests build twine toml - name: Run release script env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - VERSION: ${{ github.event.inputs.version }} TEST_PYPI_API_TOKEN: ${{ secrets.TEST_PYPI_API_TOKEN }} PYPI_API_TOKEN: ${{ secrets.PYPI_API_TOKEN }} run: | - python scripts/release.py --version ${{ github.event.inputs.version }} + python scripts/release.py diff --git a/scripts/generate_release_notes.py b/scripts/generate_release_notes.py new file mode 100644 index 0000000..1a00edb --- /dev/null +++ b/scripts/generate_release_notes.py @@ -0,0 +1,192 @@ +#!/usr/bin/env python3 + +import argparse +import re +import sys +from pathlib import Path +from typing import List, Tuple + + +class ChangelogParser: + def __init__(self, changelog_path: Path = Path("CHANGELOG.md")): + self.changelog_path = changelog_path + if not self.changelog_path.exists(): + raise FileNotFoundError(f"Changelog file not found: {self.changelog_path}") + + def extract_changelog_entry(self, version: str) -> str: + """Extract changelog entry for a specific version""" + content = self.changelog_path.read_text(encoding="utf-8") + + # Pattern to match version headers: ## [VERSION] + version_pattern = rf"^## \[{re.escape(version)}\].*$" + next_version_pattern = r"^## \[.*\].*$" + + lines = content.splitlines() + + # Find the start of the target version section + start_idx = None + for i, line in enumerate(lines): + if re.match(version_pattern, line): + start_idx = i + 1 # Start after the version header + break + + if start_idx is None: + raise ValueError(f"Version {version} not found in changelog") + + # Find the end of the section (next version header) + end_idx = len(lines) + for i in range(start_idx, len(lines)): + if re.match(next_version_pattern, lines[i]): + end_idx = i + break + + # Extract the content between version headers + entry_lines = lines[start_idx:end_idx] + + # Clean up: remove empty lines at start/end and strip trailing whitespace + while entry_lines and not entry_lines[0].strip(): + entry_lines.pop(0) + while entry_lines and not entry_lines[-1].strip(): + entry_lines.pop() + + entry_content = "\n".join(line.rstrip() for line in entry_lines) + + if not entry_content.strip(): + raise ValueError(f"No content found for version {version}") + + return entry_content + + def list_all_versions(self) -> List[Tuple[str, str]]: + """List all versions in the changelog with their dates""" + content = self.changelog_path.read_text(encoding="utf-8") + lines = content.splitlines() + + versions = [] + version_pattern = r"^## \[([^\]]+)\](?:\s*-\s*(.+))?.*$" + + for line in lines: + match = re.match(version_pattern, line) + if match: + version = match.group(1) + date = match.group(2) if match.group(2) else "No date" + if version.lower() != "unreleased": # Skip "Unreleased" section + versions.append((version, date)) + + return versions + + def validate_changelog_format(self) -> List[str]: + """Validate changelog format and return list of issues""" + content = self.changelog_path.read_text(encoding="utf-8") + lines = content.splitlines() + issues = [] + + # Check if file starts with proper header + if not lines or not lines[0].strip().startswith("# Changelog"): + issues.append("Changelog should start with '# Changelog' header") + + # Check for proper version format + version_pattern = r"^## \[([^\]]+)\](?:\s*-\s*(.+))?.*$" + found_versions = [] + + for i, line in enumerate(lines): + if re.match(r"^## \[", line): + match = re.match(version_pattern, line) + if not match: + issues.append(f"Line {i + 1}: Invalid version header format: {line}") + else: + version = match.group(1) + found_versions.append(version) + + # Check version format (except for "Unreleased") + if version.lower() != "unreleased": + if not re.match(r"^\d+\.\d+\.\d+", version): + issues.append(f"Line {i + 1}: Version should follow semantic versioning: {version}") + + if not found_versions: + issues.append("No version sections found in changelog") + + return issues + + def get_latest_version(self) -> str: + """Get the latest version from changelog (excluding Unreleased)""" + versions = self.list_all_versions() + if not versions: + raise ValueError("No versions found in changelog") + return versions[0][0] # First version in list (should be latest) + + +def main(): + parser = argparse.ArgumentParser(description="MFLUX Changelog Parser and Release Notes Generator") + parser.add_argument("--changelog", default="CHANGELOG.md", help="Path to changelog file") + + subparsers = parser.add_subparsers(dest="command", help="Available commands") + + # Show changelog entry command + show_parser = subparsers.add_parser("show", help="Show changelog entry for a version") + show_parser.add_argument("version", help="Version to show (e.g., 0.8.0)") + show_parser.add_argument("--markdown", action="store_true", help="Output in markdown format") + + # List versions command + subparsers.add_parser("list", help="List all versions in changelog") + + # Validate command + subparsers.add_parser("validate", help="Validate changelog format") + + # Latest version command + subparsers.add_parser("latest", help="Show latest version") + + args = parser.parse_args() + + if not args.command: + parser.print_help() + return + + try: + changelog_parser = ChangelogParser(Path(args.changelog)) + + if args.command == "show": + entry = changelog_parser.extract_changelog_entry(args.version) + + if args.markdown: + print(f"# Release {args.version}\n") + print(entry) + print(f"\n---\n*Changelog entry: {len(entry)} characters*") + else: + print(f"๐Ÿ“ Changelog entry for version {args.version}:") + print("=" * 60) + print(entry) + print("=" * 60) + print(f"โœ… Found changelog entry ({len(entry)} characters)") + + elif args.command == "list": + versions = changelog_parser.list_all_versions() + print("๐Ÿ“‹ Available versions in changelog:") + print("=" * 50) + for version, date in versions: + print(f" {version:<15} - {date}") + print(f"\nโœ… Found {len(versions)} versions") + + elif args.command == "validate": + issues = changelog_parser.validate_changelog_format() + if issues: + print("โŒ Changelog validation failed:") + for issue in issues: + print(f" โ€ข {issue}") + sys.exit(1) + else: + print("โœ… Changelog format is valid") + + elif args.command == "latest": + latest = changelog_parser.get_latest_version() + print(f"๐Ÿ“ฆ Latest version: {latest}") + + except (ValueError, FileNotFoundError) as e: + print(f"โŒ Error: {e}") + sys.exit(1) + except (OSError, ImportError, KeyboardInterrupt) as e: + print(f"โŒ Unexpected error: {e}") + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/scripts/release.py b/scripts/release.py index b9f5628..25e458c 100644 --- a/scripts/release.py +++ b/scripts/release.py @@ -8,6 +8,7 @@ from pathlib import Path from typing import Optional import requests +from generate_release_notes import ChangelogParser class ReleaseManager: @@ -20,6 +21,76 @@ class ReleaseManager: "Accept": "application/vnd.github.v3+json", "User-Agent": "MFLUX-Release-Script", } + # Package name on (Test)PyPI โ€“ derive from repo slug for now + self.package_name = self.github_repo.split("/")[1] + + @staticmethod + def get_version_from_pyproject(pyproject_path: Path = Path("pyproject.toml")) -> str: + """Read version from pyproject.toml""" + if not pyproject_path.exists(): + raise FileNotFoundError(f"pyproject.toml not found at {pyproject_path}") + + import toml + + try: + pyproject_data = toml.load(pyproject_path) + version = pyproject_data.get("project", {}).get("version") + if not version: + raise ValueError("Version not found in pyproject.toml under [project] section") + return version + except (toml.TomlDecodeError, KeyError, TypeError) as e: + raise ValueError(f"Failed to read version from pyproject.toml: {e}") + + @staticmethod + def validate_release_ready(version: str) -> None: + """Validate that everything is ready for release""" + # Check if version looks valid + import re + + if not re.match(r"^\d+\.\d+\.\d+", version): + raise ValueError(f"Version format appears invalid: {version}") + + # Check if changelog has entry for this version and validate version consistency + try: + changelog_parser = ChangelogParser() + changelog_parser.extract_changelog_entry(version) # Just validate entry exists + print(f"โœ… Changelog entry found for version {version}") + + # Validate that pyproject.toml version matches the latest version in changelog + latest_changelog_version = changelog_parser.get_latest_version() + if version != latest_changelog_version: + raise ValueError( + f"Version mismatch: pyproject.toml has version '{version}' but latest changelog version is '{latest_changelog_version}'. " + f"Please ensure pyproject.toml version matches the latest changelog entry." + ) + print(f"โœ… Version consistency validated: pyproject.toml ({version}) matches latest changelog entry") + + except ValueError as e: + raise ValueError(f"Changelog validation failed: {e}") + + # Check that we are releasing from the main branch. + # In local runs we can use `git branch --show-current`, but inside GitHub Actions we are in a + # detached-HEAD state. In that case `GITHUB_REF_NAME` (or `GITHUB_HEAD_REF` for PRs) exposes + # the branch name. + current_branch = os.getenv("GITHUB_REF_NAME") or os.getenv("GITHUB_HEAD_REF") + + if not current_branch: + # Fallback to local git query + try: + result = subprocess.run(["git", "branch", "--show-current"], capture_output=True, text=True, check=True) + current_branch = result.stdout.strip() + except subprocess.CalledProcessError: + current_branch = "" + + if current_branch != "main": + raise ValueError( + f"Release must be from 'main' branch, currently on '{current_branch or 'UNKNOWN'}'. " + "Please switch to main branch first or ensure the workflow checks out 'main'." + ) + + print(f"โœ… On main branch ({current_branch})") + + print(f"โœ… Release validation passed for version {version}") @staticmethod def extract_version_from_ref(github_ref: str) -> str: @@ -27,59 +98,44 @@ class ReleaseManager: raise ValueError(f"Invalid GitHub ref format: {github_ref}") return github_ref.replace("refs/tags/v.", "") - @staticmethod - def extract_changelog_entry(version: str, changelog_path: Path = Path("CHANGELOG.md")) -> str: - import re - - if not changelog_path.exists(): - raise FileNotFoundError(f"Changelog file not found: {changelog_path}") - - content = changelog_path.read_text(encoding="utf-8") - - # Pattern to match version headers: ## [VERSION] - version_pattern = rf"^## \[{re.escape(version)}\].*$" - next_version_pattern = r"^## \[.*\].*$" - - lines = content.splitlines() - - # Find the start of the target version section - start_idx = None - for i, line in enumerate(lines): - if re.match(version_pattern, line): - start_idx = i + 1 # Start after the version header - break - - if start_idx is None: - raise ValueError(f"Version {version} not found in changelog") - - # Find the end of the section (next version header) - end_idx = len(lines) - for i in range(start_idx, len(lines)): - if re.match(next_version_pattern, lines[i]): - end_idx = i - break - - # Extract the content between version headers - entry_lines = lines[start_idx:end_idx] - - # Clean up: remove empty lines at start/end and strip trailing whitespace - while entry_lines and not entry_lines[0].strip(): - entry_lines.pop(0) - while entry_lines and not entry_lines[-1].strip(): - entry_lines.pop() - - entry_content = "\n".join(line.rstrip() for line in entry_lines) - - if not entry_content.strip(): - raise ValueError(f"No content found for version {version}") - - return entry_content - def check_release_exists(self, tag_name: str) -> bool: url = f"{self.base_url}/repos/{self.github_repo}/releases/tags/{tag_name}" response = requests.get(url, headers=self.headers) return response.status_code == 200 + def check_git_tag_exists(self, tag_name: str) -> bool: + """Check if a git tag already exists locally or remotely""" + try: + # Check if tag exists locally + result = subprocess.run(["git", "tag", "-l", tag_name], capture_output=True, text=True, check=True) + if result.stdout.strip() == tag_name: + print(f"โœ… Git tag {tag_name} exists locally") + return True + + # Check if tag exists on remote + result = subprocess.run( + ["git", "ls-remote", "--tags", "origin", f"refs/tags/{tag_name}"], + capture_output=True, + text=True, + check=True, + ) + if result.stdout.strip(): + print(f"โœ… Git tag {tag_name} exists on remote") + return True + + return False + except subprocess.CalledProcessError as e: + print(f"โš ๏ธ Warning: Could not check git tag existence: {e}") + return False + + def create_git_tag(self, tag_name: str, version: str) -> None: + """Create and push git tag for the release""" + # Create annotated tag + self.run_command(["git", "tag", "-a", tag_name, "-m", f"Release {version}"], f"Creating git tag {tag_name}") + + # Push tag to remote + self.run_command(["git", "push", "origin", tag_name], f"Pushing git tag {tag_name} to remote") + def create_github_release(self, tag_name: str, version: str, changelog_entry: str) -> dict: url = f"{self.base_url}/repos/{self.github_repo}/releases" @@ -100,11 +156,11 @@ class ReleaseManager: raise Exception(f"Failed to create GitHub release: {response.status_code} - {response.text}") @staticmethod - def run_command(cmd: list, description: str, check: bool = True) -> subprocess.CompletedProcess: + def run_command(cmd: list, description: str, check: bool = True, env: dict = None) -> subprocess.CompletedProcess: print(f"๐Ÿ”„ {description}...") print(f" Running: {' '.join(cmd)}") - result = subprocess.run(cmd, capture_output=True, text=True, check=False) + result = subprocess.run(cmd, capture_output=True, text=True, check=False, env=env) if result.returncode != 0 and check: print(f"โŒ {description} failed!") @@ -125,14 +181,30 @@ class ReleaseManager: "Installing build dependencies", ) + # Clean dist directory to ensure fresh build + import shutil + + dist_dir = Path("dist") + if dist_dir.exists(): + shutil.rmtree(dist_dir) + print("๐Ÿงน Cleaned dist/ directory") + # Build the package self.run_command([sys.executable, "-m", "build"], "Building package") - def publish_to_test_pypi(self, test_pypi_token: Optional[str]): + def verify_package(self): + """Run *twine check* to verify that README / long_description renders on PyPI.""" + self.run_command([sys.executable, "-m", "twine", "check", "dist/*"], "Verifying distribution with Twine") + + def publish_to_test_pypi(self, test_pypi_token: Optional[str], version: str): if not test_pypi_token: print("โš ๏ธ Test PyPI token not provided, skipping Test PyPI upload") return + if self.version_exists_on_pypi(version, test_pypi=True): + print(f"โš ๏ธ Version {version} already exists on Test PyPI, skipping upload") + return + env = os.environ.copy() env["TWINE_USERNAME"] = "__token__" env["TWINE_PASSWORD"] = test_pypi_token @@ -140,51 +212,60 @@ class ReleaseManager: self.run_command( [sys.executable, "-m", "twine", "upload", "--repository", "testpypi", "dist/*", "--verbose"], "Publishing to Test PyPI", + env=env, ) - @staticmethod - def publish_to_pypi(pypi_token: str): + def publish_to_pypi(self, pypi_token: str, version: str): + if self.version_exists_on_pypi(version, test_pypi=False): + print(f"โš ๏ธ Version {version} already exists on PyPI, skipping upload") + return + env = os.environ.copy() env["TWINE_USERNAME"] = "__token__" env["TWINE_PASSWORD"] = pypi_token - # Set environment variables for subprocess - cmd_env = os.environ.copy() - cmd_env.update(env) - - print("๐Ÿ”„ Publishing to PyPI...") - print(f" Running: {sys.executable} -m twine upload dist/* --verbose") - - result = subprocess.run( + self.run_command( [sys.executable, "-m", "twine", "upload", "dist/*", "--verbose"], - capture_output=True, - text=True, - env=cmd_env, - check=False, + "Publishing to PyPI", + env=env, ) - if result.returncode != 0: - print("โŒ Publishing to PyPI failed!") - print(f" stdout: {result.stdout}") - print(f" stderr: {result.stderr}") - raise Exception("PyPI upload failed") + def version_exists_on_pypi(self, version: str, test_pypi: bool = False) -> bool: + """Return True if *version* of this package is already published on (Test)PyPI.""" + repo_url = "https://test.pypi.org/pypi" if test_pypi else "https://pypi.org/pypi" + url = f"{repo_url}/{self.package_name}/json" + try: + response = requests.get(url, timeout=10) + if response.status_code != 200: + return False + data = response.json() + return version in data.get("releases", {}) + except requests.RequestException: + return False - if result.stdout: - print(f" stdout: {result.stdout.strip()}") - - print("โœ… Publishing to PyPI completed successfully") - - def release(self, version: str, test_pypi_token: Optional[str], pypi_token: str): + def release(self, test_pypi_token: Optional[str], pypi_token: str): print("๐Ÿš€ Starting MFLUX release process...") - # Extract version + # Read version from pyproject.toml + version = self.get_version_from_pyproject() tag_name = f"v.{version}" - print(f"๐Ÿ“ฆ Releasing version: {version} (tag: {tag_name})") + print(f"๐Ÿ“ฆ Releasing version: {version} (tag: {tag_name}) [from pyproject.toml]") - # Extract changelog - print("๐Ÿ“ Extracting changelog entry...") - changelog_entry = self.extract_changelog_entry(version) - print(f" Found changelog entry ({len(changelog_entry)} characters)") + # Validate everything is ready for release + print("๐Ÿ” Validating release readiness...") + self.validate_release_ready(version) + + # Check for uncommitted changes + print("๐Ÿ” Checking for uncommitted changes...") + try: + result = subprocess.run(["git", "status", "--porcelain"], capture_output=True, text=True, check=True) + if result.stdout.strip(): + print("โš ๏ธ Uncommitted changes detected:") + print(result.stdout) + raise ValueError("Cannot release with uncommitted changes. Please commit or stash your changes first.") + print("โœ… No uncommitted changes found") + except subprocess.CalledProcessError: + print("โš ๏ธ Warning: Could not check git status") # Check if release exists print("๐Ÿ” Checking if release already exists...") @@ -194,28 +275,63 @@ class ReleaseManager: else: skip_github_release = False - # Create GitHub release - if not skip_github_release: - self.create_github_release(tag_name, version, changelog_entry) + # Check if git tag exists + print("๐Ÿ” Checking if git tag already exists...") + git_tag_exists = self.check_git_tag_exists(tag_name) - # Build package - self.build_package() + # Determine if this is a completely new release or if we're re-running + if skip_github_release and git_tag_exists: + print(f"โœ… Release {tag_name} already exists completely (both git tag and GitHub release)") + print("๐Ÿ”„ This appears to be a re-run of an existing release - nothing to do!") + print(" If you want to republish to PyPI, delete the GitHub release first.") + return + elif skip_github_release or git_tag_exists: + print("โš ๏ธ Partial release state detected:") + print(f" Git tag exists: {git_tag_exists}") + print(f" GitHub release exists: {skip_github_release}") + print(" Will complete the missing parts...") - # Publish to Test PyPI - if not skip_github_release: # Only publish if this is a new release - self.publish_to_test_pypi(test_pypi_token) + # Extract changelog + print("๐Ÿ“ Extracting changelog entry...") + changelog_parser = ChangelogParser() + changelog_entry = changelog_parser.extract_changelog_entry(version) + print(f" Found changelog entry ({len(changelog_entry)} characters)") - # Publish to PyPI - self.publish_to_pypi(pypi_token) + # Create git tag if it doesn't exist + if not git_tag_exists: + print("๐Ÿท๏ธ Creating git tag...") + self.create_git_tag(tag_name, version) else: - print("โš ๏ธ Skipping PyPI publishing since release already exists") + print(f"โœ… Git tag {tag_name} already exists, skipping creation") + + # Create GitHub release if it doesn't exist + if not skip_github_release: + print("๐Ÿ™ Creating GitHub release...") + self.create_github_release(tag_name, version, changelog_entry) + else: + print(f"โœ… GitHub release {tag_name} already exists, skipping creation") + + # Build & verify package + self.build_package() + self.verify_package() + + # Only publish to PyPI if this is a new release (both git tag and GitHub release were created) + if not skip_github_release and not git_tag_exists: + print("๐Ÿ“ฆ Publishing to PyPI (new release)...") + # Publish to Test PyPI + self.publish_to_test_pypi(test_pypi_token, version) + # Publish to PyPI + self.publish_to_pypi(pypi_token, version) + else: + print("โš ๏ธ Skipping PyPI publishing since this appears to be a re-run") + print(" (Either git tag or GitHub release already existed)") + print(" If you need to republish to PyPI, delete both the git tag and GitHub release first.") print(f"๐ŸŽ‰ Release process completed successfully for version {version}!") def main(): parser = argparse.ArgumentParser(description="MFLUX Release Management") - parser.add_argument("--version", help="Release version (e.g., 0.8.0)") parser.add_argument("--github-token", help="GitHub token") parser.add_argument("--test-pypi-token", help="Test PyPI API token (optional)") parser.add_argument("--pypi-token", help="PyPI API token") @@ -228,7 +344,8 @@ def main(): # Handle --show-changelog option if args.show_changelog: try: - changelog_entry = ReleaseManager.extract_changelog_entry(args.show_changelog) + changelog_parser = ChangelogParser() + changelog_entry = changelog_parser.extract_changelog_entry(args.show_changelog) if args.markdown: # Output as Markdown @@ -248,16 +365,11 @@ def main(): return # Get values from args or environment variables - version = args.version or os.getenv("VERSION") github_token = args.github_token or os.getenv("GITHUB_TOKEN") test_pypi_token = args.test_pypi_token or os.getenv("TEST_PYPI_API_TOKEN") pypi_token = args.pypi_token or os.getenv("PYPI_API_TOKEN") # Validate required parameters - if not version: - print("โŒ Version is required (--version or VERSION env var)") - sys.exit(1) - if not github_token: print("โŒ GitHub token is required (--github-token or GITHUB_TOKEN env var)") sys.exit(1) @@ -267,15 +379,20 @@ def main(): sys.exit(1) if args.dry_run: - print("๐Ÿ” DRY RUN MODE - would execute release process with:") - print(f" Version: {version}") - print(f" Has Test PyPI token: {bool(test_pypi_token)}") - print(f" Has PyPI token: {bool(pypi_token)}") - return + try: + version = ReleaseManager.get_version_from_pyproject() + print("๐Ÿ” DRY RUN MODE - would execute release process with:") + print(f" Version: {version} [from pyproject.toml]") + print(f" Has Test PyPI token: {bool(test_pypi_token)}") + print(f" Has PyPI token: {bool(pypi_token)}") + return + except (ValueError, FileNotFoundError) as e: + print(f"โŒ Could not read version for dry run: {e}") + sys.exit(1) try: release_manager = ReleaseManager(github_token) - release_manager.release(version, test_pypi_token, pypi_token) + release_manager.release(test_pypi_token, pypi_token) except (ValueError, FileNotFoundError, requests.RequestException) as e: print(f"โŒ Release failed: {e}") sys.exit(1)