Update release script (#211)

This commit is contained in:
Filip Strand 2025-06-14 11:18:27 +02:00 committed by GitHub
parent 9a20e83331
commit ea4aca5850
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 426 additions and 111 deletions

View File

@ -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

View File

@ -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()

View File

@ -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)