Update release script (#211)
This commit is contained in:
parent
9a20e83331
commit
ea4aca5850
16
.github/workflows/release.yml
vendored
16
.github/workflows/release.yml
vendored
@ -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
|
||||
|
||||
192
scripts/generate_release_notes.py
Normal file
192
scripts/generate_release_notes.py
Normal 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()
|
||||
@ -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)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user