Release v.0.9.4 (#237)
This commit is contained in:
parent
aceeafca8e
commit
3c5df450cc
4
.github/workflows/release.yml
vendored
4
.github/workflows/release.yml
vendored
@ -46,7 +46,7 @@ jobs:
|
||||
- name: Run release script
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TEST_PYPI_API_TOKEN: ${{ secrets.TEST_PYPI_API_TOKEN }}
|
||||
PYPI_API_TOKEN: ${{ secrets.PYPI_API_TOKEN }}
|
||||
TEST_PYPI_API_TOKEN: ${{ secrets.TEST_PYPI_API_TOKEN }}
|
||||
run: |
|
||||
python scripts/release.py
|
||||
python -m mflux.release.release_script
|
||||
|
||||
18
CHANGELOG.md
18
CHANGELOG.md
@ -5,15 +5,29 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [0.9.4] - 2025-07-17
|
||||
|
||||
# MFLUX v.0.9.4 Release Notes
|
||||
|
||||
### 🛠️ Dependency Updates
|
||||
|
||||
- Expanded MLX dependency range from `mlx>=0.22.0,<=0.26.1` to `mlx>=0.22.0,<0.27.0` to support newer MLX versions
|
||||
|
||||
### 🔧 Developer Experience
|
||||
|
||||
- Refactor the release script into a reusable Python module for better maintainability
|
||||
|
||||
## [0.9.3] - 2025-07-08
|
||||
|
||||
# MFLUX v.0.9.3 Release Notes
|
||||
|
||||
### 😖 Revert "Offline Resilience" change
|
||||
|
||||
On a "cold start" where user has not previously downloaded the requested model, the workflow does not successfully request the download of all the expected files, blocking the image generation workflow for first time users. The feature will be re-evaluated carefully after this hot fix.
|
||||
|
||||
## [0.9.2] - 2025-07-08
|
||||
|
||||
# MFLUX v0.9.2 Release Notes
|
||||
# MFLUX v.0.9.2 Release Notes
|
||||
|
||||
### 🏗️ Build System Improvements
|
||||
|
||||
@ -41,7 +55,7 @@ On a "cold start" where user has not previously downloaded the requested model,
|
||||
|
||||
## [0.9.1] - 2025-07-04
|
||||
|
||||
# MFLUX v0.9.1 Release Notes
|
||||
# MFLUX v.0.9.1 Release Notes
|
||||
|
||||
### 🛠️ Dependency Fixes
|
||||
|
||||
|
||||
@ -15,7 +15,7 @@ source-exclude = [
|
||||
|
||||
[project]
|
||||
name = "mflux"
|
||||
version = "0.9.3"
|
||||
version = "0.9.4"
|
||||
description = "A MLX port of FLUX based on the Huggingface Diffusers implementation."
|
||||
readme = "README.md"
|
||||
keywords = ["diffusers", "flux", "mlx"]
|
||||
@ -26,7 +26,7 @@ requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"huggingface-hub>=0.24.5,<1.0",
|
||||
"matplotlib>=3.9.2,<4.0",
|
||||
"mlx>=0.22.0,<=0.26.1",
|
||||
"mlx>=0.22.0,<0.27.0",
|
||||
"numpy>=2.0.1,<3.0",
|
||||
"opencv-python>=4.10.0,<5.0",
|
||||
"piexif>=1.1.3,<2.0",
|
||||
@ -38,13 +38,14 @@ dependencies = [
|
||||
# use temporary community build of py13 wheel, use until official project build
|
||||
# uv pip install https://github.com/anthonywu/sentencepiece/releases/download/0.2.1-py13dev/sentencepiece-0.2.1-cp313-cp313-macosx_11_0_arm64.whl
|
||||
"sentencepiece>=0.2.0,<1.0; python_version<'3.13'",
|
||||
"tokenizers>=0.20.3; python_version>='3.13'", # transformers -> tokenizers
|
||||
"tokenizers>=0.20.3; python_version>='3.13'", # transformers -> tokenizers
|
||||
"toml>=0.10.2,<1.0",
|
||||
"torch>=2.3.1,<3.0; python_version<'3.13'",
|
||||
# torch dev builds: pip install --pre --index-url https://download.pytorch.org/whl/nightly
|
||||
"torch>=2.6.0.dev20241106; python_version>='3.13'",
|
||||
"tqdm>=4.66.5,<5.0",
|
||||
"transformers>=4.44.0,<5.0",
|
||||
"twine>=6.1.0,<7.0",
|
||||
]
|
||||
classifiers = [
|
||||
"Intended Audience :: Developers",
|
||||
|
||||
@ -1,192 +0,0 @@
|
||||
#!/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()
|
||||
@ -1,414 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import requests
|
||||
from generate_release_notes import ChangelogParser
|
||||
|
||||
|
||||
class ReleaseManager:
|
||||
def __init__(self, github_token: str):
|
||||
self.github_token = github_token
|
||||
self.github_repo = "filipstrand/mflux"
|
||||
self.base_url = "https://api.github.com"
|
||||
self.headers = {
|
||||
"Authorization": f"token {github_token}",
|
||||
"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:
|
||||
if not github_ref.startswith("refs/tags/v."):
|
||||
raise ValueError(f"Invalid GitHub ref format: {github_ref}")
|
||||
return github_ref.replace("refs/tags/v.", "")
|
||||
|
||||
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"
|
||||
|
||||
data = {
|
||||
"tag_name": tag_name,
|
||||
"name": f"Release {version}",
|
||||
"body": changelog_entry,
|
||||
"draft": False,
|
||||
"prerelease": False,
|
||||
}
|
||||
|
||||
response = requests.post(url, json=data, headers=self.headers)
|
||||
|
||||
if response.status_code == 201:
|
||||
print(f"✅ Successfully created GitHub release for {tag_name}")
|
||||
return response.json()
|
||||
else:
|
||||
raise Exception(f"Failed to create GitHub release: {response.status_code} - {response.text}")
|
||||
|
||||
@staticmethod
|
||||
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, env=env)
|
||||
|
||||
if result.returncode != 0 and check:
|
||||
print(f"❌ {description} failed!")
|
||||
print(f" stdout: {result.stdout}")
|
||||
print(f" stderr: {result.stderr}")
|
||||
raise Exception(f"Command failed: {' '.join(cmd)}")
|
||||
|
||||
if result.stdout:
|
||||
print(f" stdout: {result.stdout.strip()}")
|
||||
|
||||
print(f"✅ {description} completed successfully")
|
||||
return result
|
||||
|
||||
def build_package(self):
|
||||
# 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", "uv", "--version"], "Verify 'uv build' version")
|
||||
self.run_command([sys.executable, "-m", "uv", "build"], "Building package with 'uv build'")
|
||||
|
||||
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
|
||||
|
||||
self.run_command(
|
||||
[sys.executable, "-m", "twine", "upload", "--repository", "testpypi", "dist/*", "--verbose"],
|
||||
"Publishing to Test PyPI",
|
||||
env=env,
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
try:
|
||||
self.run_command(
|
||||
[sys.executable, "-m", "twine", "upload", "dist/*", "--verbose"],
|
||||
"Publishing to PyPI",
|
||||
env=env,
|
||||
)
|
||||
except Exception as e:
|
||||
if "already been used" in str(e) or "filename has already been used" in str(e):
|
||||
print(f"⚠️ Version {version} already exists on PyPI (detected during upload)")
|
||||
print(" This can happen due to race conditions or previous partial uploads")
|
||||
print(" The release process will continue, but PyPI publishing was skipped")
|
||||
return
|
||||
else:
|
||||
raise
|
||||
|
||||
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
|
||||
|
||||
def release(self, test_pypi_token: Optional[str], pypi_token: str):
|
||||
print("🚀 Starting MFLUX release process...")
|
||||
|
||||
# Read version from pyproject.toml
|
||||
version = self.get_version_from_pyproject()
|
||||
tag_name = f"v.{version}"
|
||||
print(f"📦 Releasing version: {version} (tag: {tag_name}) [from pyproject.toml]")
|
||||
|
||||
# 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...")
|
||||
if self.check_release_exists(tag_name):
|
||||
print(f"⚠️ Release {tag_name} already exists, skipping GitHub release creation")
|
||||
skip_github_release = True
|
||||
else:
|
||||
skip_github_release = False
|
||||
|
||||
# Check if git tag exists
|
||||
print("🔍 Checking if git tag already exists...")
|
||||
git_tag_exists = self.check_git_tag_exists(tag_name)
|
||||
|
||||
# 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...")
|
||||
|
||||
# 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)")
|
||||
|
||||
# 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(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()
|
||||
|
||||
# Check if version already exists on PyPI before attempting to publish
|
||||
if self.version_exists_on_pypi(version, test_pypi=False):
|
||||
print(f"⚠️ Version {version} already exists on PyPI, skipping all PyPI publishing")
|
||||
return
|
||||
|
||||
# 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("--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")
|
||||
parser.add_argument("--dry-run", action="store_true", help="Print what would be done without executing")
|
||||
parser.add_argument("--show-changelog", help="Show changelog entry for a specific version (e.g., 0.8.0) and exit")
|
||||
parser.add_argument("--markdown", action="store_true", help="Output changelog in markdown format (use with --show-changelog)") # fmt: off
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Handle --show-changelog option
|
||||
if args.show_changelog:
|
||||
try:
|
||||
changelog_parser = ChangelogParser()
|
||||
changelog_entry = changelog_parser.extract_changelog_entry(args.show_changelog)
|
||||
|
||||
if args.markdown:
|
||||
# Output as Markdown
|
||||
print(f"# Release {args.show_changelog}\n")
|
||||
print(changelog_entry)
|
||||
print(f"\n---\n*Changelog entry: {len(changelog_entry)} characters*")
|
||||
else:
|
||||
# Output with separators (original format)
|
||||
print(f"📝 Changelog entry for version {args.show_changelog}:")
|
||||
print("=" * 60)
|
||||
print(changelog_entry)
|
||||
print("=" * 60)
|
||||
print(f"✅ Found changelog entry ({len(changelog_entry)} characters)")
|
||||
except (ValueError, FileNotFoundError) as e:
|
||||
print(f"❌ Failed to extract changelog: {e}")
|
||||
sys.exit(1)
|
||||
return
|
||||
|
||||
# Get values from args or environment variables
|
||||
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 github_token:
|
||||
print("❌ GitHub token is required (--github-token or GITHUB_TOKEN env var)")
|
||||
sys.exit(1)
|
||||
|
||||
if not pypi_token:
|
||||
print("❌ PyPI token is required (--pypi-token or PYPI_API_TOKEN env var)")
|
||||
sys.exit(1)
|
||||
|
||||
if args.dry_run:
|
||||
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(test_pypi_token, pypi_token)
|
||||
except (ValueError, FileNotFoundError, requests.RequestException) as e:
|
||||
print(f"❌ Release failed: {e}")
|
||||
sys.exit(1)
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"❌ Release failed with unexpected error: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@ -11,7 +11,7 @@ from mflux.dreambooth.state.training_spec import SingleTransformerBlocks, Traini
|
||||
from mflux.dreambooth.state.zip_util import ZipUtil
|
||||
from mflux.models.transformer.joint_transformer_block import JointTransformerBlock
|
||||
from mflux.models.transformer.single_transformer_block import SingleTransformerBlock
|
||||
from mflux.post_processing.generated_image import GeneratedImage
|
||||
from mflux.utils.version_util import VersionUtil
|
||||
from mflux.weights.weight_handler import MetaData, WeightHandler
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@ -58,7 +58,7 @@ class LoRALayers:
|
||||
lora_layers = {**transformer_lora_layers, **single_transformer_lora_layers}
|
||||
|
||||
weights = WeightHandler(
|
||||
meta_data=MetaData(mflux_version=GeneratedImage.get_version()),
|
||||
meta_data=MetaData(mflux_version=VersionUtil.get_mflux_version()),
|
||||
transformer=mlx.utils.tree_unflatten(list(lora_layers.items()))["transformer"],
|
||||
)
|
||||
|
||||
@ -272,7 +272,7 @@ class LoRALayers:
|
||||
str(path),
|
||||
dict(tree_flatten(weights)),
|
||||
metadata={
|
||||
"mflux_version": GeneratedImage.get_version(),
|
||||
"mflux_version": VersionUtil.get_mflux_version(),
|
||||
"transformer_blocks": str(training_spec.lora_layers.transformer_blocks),
|
||||
"single_transformer_blocks": str(training_spec.lora_layers.single_transformer_blocks),
|
||||
},
|
||||
|
||||
@ -24,3 +24,16 @@ class StopImageGenerationException(MFluxUserException):
|
||||
|
||||
class StopTrainingException(MFluxUserException):
|
||||
"""user has requested to stop the training process in progress."""
|
||||
|
||||
|
||||
class CommandExecutionError(MFluxException):
|
||||
"""Raised when a subprocess command invoked by mflux tooling fails."""
|
||||
|
||||
def __init__(self, cmd: list[str], return_code: int, stdout: str | None, stderr: str | None):
|
||||
self.cmd = cmd
|
||||
self.return_code = return_code
|
||||
self.stdout = stdout or ""
|
||||
self.stderr = stderr or ""
|
||||
super().__init__(
|
||||
f"Command '{' '.join(cmd)}' exited with code {return_code}. See .stdout / .stderr for details."
|
||||
)
|
||||
|
||||
@ -1,12 +1,11 @@
|
||||
import importlib
|
||||
from pathlib import Path
|
||||
|
||||
import mlx.core as mx
|
||||
import PIL.Image
|
||||
import toml
|
||||
|
||||
from mflux.community.concept_attention.attention_data import ConceptHeatmap
|
||||
from mflux.config.model_config import ModelConfig
|
||||
from mflux.utils.version_util import VersionUtil
|
||||
|
||||
|
||||
class GeneratedImage:
|
||||
@ -107,7 +106,10 @@ class GeneratedImage:
|
||||
self.save_concept_heatmap(path=heatmap_path, export_json_metadata=export_json_metadata, overwrite=overwrite)
|
||||
|
||||
def save_concept_heatmap(
|
||||
self, path: str | Path, export_json_metadata: bool = False, overwrite: bool = False
|
||||
self,
|
||||
path: str | Path,
|
||||
export_json_metadata: bool = False,
|
||||
overwrite: bool = False,
|
||||
) -> None:
|
||||
if self.concept_heatmap:
|
||||
from mflux import ImageUtil
|
||||
@ -129,7 +131,7 @@ class GeneratedImage:
|
||||
|
||||
def _get_metadata(self) -> dict:
|
||||
return {
|
||||
"mflux_version": GeneratedImage.get_version(),
|
||||
"mflux_version": VersionUtil.get_mflux_version(),
|
||||
"model": self.model_config.model_name,
|
||||
"base_model": str(self.model_config.base_model),
|
||||
"seed": self.seed,
|
||||
@ -150,29 +152,3 @@ class GeneratedImage:
|
||||
"redux_image_strengths": self._format_redux_strengths(),
|
||||
"prompt": self.prompt,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def get_version() -> str:
|
||||
version = GeneratedImage._get_version_from_toml()
|
||||
if version:
|
||||
return version
|
||||
|
||||
# Fallback to an installed package version
|
||||
try:
|
||||
return str(importlib.metadata.version("mflux"))
|
||||
except importlib.metadata.PackageNotFoundError:
|
||||
return "unknown"
|
||||
|
||||
@staticmethod
|
||||
def _get_version_from_toml() -> str | None:
|
||||
# Search for pyproject.toml by traversing up from the current working directory
|
||||
current_dir = Path(__file__).resolve().parent
|
||||
for parent in current_dir.parents:
|
||||
pyproject_path = parent / "pyproject.toml"
|
||||
if pyproject_path.exists():
|
||||
try:
|
||||
pyproject_data = toml.load(pyproject_path)
|
||||
return pyproject_data.get("project", {}).get("version")
|
||||
except (toml.TomlDecodeError, KeyError, TypeError):
|
||||
return None
|
||||
return None
|
||||
|
||||
0
src/mflux/release/__init__.py
Normal file
0
src/mflux/release/__init__.py
Normal file
116
src/mflux/release/changelog_parser.py
Normal file
116
src/mflux/release/changelog_parser.py
Normal file
@ -0,0 +1,116 @@
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import List, Tuple
|
||||
|
||||
|
||||
class ChangelogParser:
|
||||
@staticmethod
|
||||
def extract_release_notes_from_changelog(version: str, changelog_path: Path = Path("CHANGELOG.md")) -> str:
|
||||
print("📝 Extracting changelog entry...")
|
||||
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}")
|
||||
|
||||
print(f" Found changelog entry ({len(entry_content)} characters)")
|
||||
return entry_content
|
||||
|
||||
@staticmethod
|
||||
def validate_changelog_format(changelog_path: Path = Path("CHANGELOG.md")) -> List[str]:
|
||||
if not changelog_path.exists():
|
||||
raise FileNotFoundError(f"Changelog file not found: {changelog_path}")
|
||||
|
||||
content = 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
|
||||
|
||||
@staticmethod
|
||||
def get_latest_version(changelog_path: Path = Path("CHANGELOG.md")) -> str:
|
||||
versions = ChangelogParser._list_all_versions(changelog_path)
|
||||
if not versions:
|
||||
raise ValueError("No versions found in changelog")
|
||||
return versions[0][0] # First version in list (should be latest)
|
||||
|
||||
@staticmethod
|
||||
def _list_all_versions(changelog_path: Path = Path("CHANGELOG.md")) -> List[Tuple[str, str]]:
|
||||
if not changelog_path.exists():
|
||||
raise FileNotFoundError(f"Changelog file not found: {changelog_path}")
|
||||
|
||||
content = 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
|
||||
59
src/mflux/release/git_operations.py
Normal file
59
src/mflux/release/git_operations.py
Normal file
@ -0,0 +1,59 @@
|
||||
import subprocess
|
||||
|
||||
from mflux.error.exceptions import CommandExecutionError
|
||||
|
||||
|
||||
class GitOperations:
|
||||
@staticmethod
|
||||
def run_command(cmd: list, description: str, check: bool = True) -> subprocess.CompletedProcess:
|
||||
print(f"🔄 {description}...")
|
||||
print(f" Running: {' '.join(cmd)}")
|
||||
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
|
||||
|
||||
if result.returncode != 0 and check:
|
||||
print(f"❌ {description} failed!")
|
||||
print(f" stdout: {result.stdout}")
|
||||
print(f" stderr: {result.stderr}")
|
||||
raise CommandExecutionError(cmd, result.returncode, result.stdout, result.stderr)
|
||||
|
||||
if result.stdout:
|
||||
print(f" stdout: {result.stdout.strip()}")
|
||||
|
||||
print(f"✅ {description} completed successfully")
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def check_tag_exists(tag_name: str) -> bool:
|
||||
print("🔍 Checking git tag existence...")
|
||||
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
|
||||
|
||||
print(f" Git tag {tag_name} does not exist")
|
||||
return False
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f"⚠️ Warning: Could not check git tag existence: {e}")
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def create_and_push_tag(tag_name: str, version: str) -> None:
|
||||
print("🏷️ Creating git tag...")
|
||||
GitOperations.run_command(
|
||||
["git", "tag", "-a", tag_name, "-m", f"Release {version}"], f"Creating git tag {tag_name}"
|
||||
)
|
||||
GitOperations.run_command(["git", "push", "origin", tag_name], f"Pushing git tag {tag_name} to remote")
|
||||
66
src/mflux/release/github_api.py
Normal file
66
src/mflux/release/github_api.py
Normal file
@ -0,0 +1,66 @@
|
||||
import requests
|
||||
|
||||
|
||||
class GitHubAPI:
|
||||
@staticmethod
|
||||
def check_github_release_exists(
|
||||
github_token: str,
|
||||
github_repo: str,
|
||||
tag_name: str,
|
||||
) -> bool:
|
||||
print("🔍 Checking GitHub release existence...")
|
||||
|
||||
headers = {
|
||||
"Authorization": f"token {github_token}",
|
||||
"Accept": "application/vnd.github.v3+json",
|
||||
"User-Agent": "MFLUX-Release-Script",
|
||||
}
|
||||
|
||||
url = f"https://api.github.com/repos/{github_repo}/releases/tags/{tag_name}"
|
||||
response = requests.get(url, headers=headers)
|
||||
|
||||
if response.status_code == 200:
|
||||
print(f"✅ GitHub release {tag_name} exists")
|
||||
return True
|
||||
|
||||
if response.status_code == 404:
|
||||
print(f" GitHub release {tag_name} does not exist")
|
||||
return False
|
||||
|
||||
# Any other status code is unexpected and likely indicates auth/rate-limit issues.
|
||||
error_msg = f"GitHub API returned {response.status_code} while checking for release {tag_name}: {response.text}"
|
||||
raise requests.HTTPError(error_msg, response=response)
|
||||
|
||||
@staticmethod
|
||||
def create_github_release(
|
||||
github_token: str,
|
||||
github_repo: str,
|
||||
tag_name: str,
|
||||
version: str,
|
||||
release_notes: str,
|
||||
) -> dict:
|
||||
print("🐙 Creating GitHub release...")
|
||||
|
||||
headers = {
|
||||
"Authorization": f"token {github_token}",
|
||||
"Accept": "application/vnd.github.v3+json",
|
||||
"User-Agent": "MFLUX-Release-Script",
|
||||
}
|
||||
|
||||
url = f"https://api.github.com/repos/{github_repo}/releases"
|
||||
|
||||
data = {
|
||||
"tag_name": tag_name,
|
||||
"name": f"Release {version}",
|
||||
"body": release_notes,
|
||||
"draft": False,
|
||||
"prerelease": False,
|
||||
}
|
||||
|
||||
response = requests.post(url, json=data, headers=headers)
|
||||
|
||||
if response.status_code == 201:
|
||||
print(f"✅ Successfully created GitHub release for {tag_name}")
|
||||
return response.json()
|
||||
else:
|
||||
raise Exception(f"Failed to create GitHub release: {response.status_code} - {response.text}")
|
||||
176
src/mflux/release/pypi_publisher.py
Normal file
176
src/mflux/release/pypi_publisher.py
Normal file
@ -0,0 +1,176 @@
|
||||
import shutil
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import requests
|
||||
from twine.commands import upload
|
||||
from twine.exceptions import TwineException
|
||||
from twine.settings import Settings
|
||||
|
||||
from .git_operations import GitOperations
|
||||
|
||||
|
||||
class PyPIPublisher:
|
||||
@staticmethod
|
||||
def build_and_verify_package() -> None:
|
||||
print("📦 Building and verifying package...")
|
||||
|
||||
# Clean dist directory
|
||||
dist_dir = Path("dist")
|
||||
if dist_dir.exists():
|
||||
shutil.rmtree(dist_dir)
|
||||
print("🧹 Cleaned dist/ directory")
|
||||
|
||||
# Build the package
|
||||
GitOperations.run_command([sys.executable, "-m", "uv", "--version"], "Verify 'uv build' version")
|
||||
GitOperations.run_command([sys.executable, "-m", "uv", "build"], "Building package with 'uv build'")
|
||||
|
||||
# Verify the package
|
||||
print("🔍 Verifying package...")
|
||||
GitOperations.run_command(
|
||||
[sys.executable, "-m", "twine", "check", "dist/*"], "Verifying distribution with Twine"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def version_exists_on_pypi(package_name: str, version: str, test_pypi: bool = False) -> bool:
|
||||
print("🔍 Checking if version already exists on PyPI...")
|
||||
repo_url = "https://test.pypi.org/pypi" if test_pypi else "https://pypi.org/pypi"
|
||||
url = f"{repo_url}/{package_name}/json"
|
||||
|
||||
for attempt in range(3):
|
||||
try:
|
||||
print(f" Checking PyPI API (attempt {attempt + 1}/3): {url}")
|
||||
response = requests.get(url, timeout=30)
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
releases = data.get("releases", {})
|
||||
version_exists = version in releases
|
||||
print(
|
||||
f" PyPI API response: {len(releases)} total versions, version {version} exists: {version_exists}"
|
||||
)
|
||||
|
||||
if version_exists:
|
||||
print(f"⚠️ Version {version} already exists on PyPI")
|
||||
|
||||
return version_exists
|
||||
elif response.status_code == 404:
|
||||
print(" Package not found on PyPI (404) - this is normal for new packages")
|
||||
return False
|
||||
else:
|
||||
print(f" PyPI API returned status {response.status_code}: {response.text[:200]}")
|
||||
if attempt < 2:
|
||||
time.sleep(2**attempt)
|
||||
continue
|
||||
else:
|
||||
raise ValueError(f"PyPI API returned unexpected status {response.status_code}")
|
||||
|
||||
except requests.RequestException as e:
|
||||
print(f" Network error checking PyPI (attempt {attempt + 1}/3): {e}")
|
||||
if attempt < 2:
|
||||
time.sleep(2**attempt)
|
||||
continue
|
||||
else:
|
||||
raise requests.RequestException(f"Failed to check PyPI after 3 attempts: {e}") from e
|
||||
|
||||
raise RuntimeError("Unexpected error in version_exists_on_pypi")
|
||||
|
||||
@staticmethod
|
||||
def publish_to_test_pypi(test_pypi_token: Optional[str], package_name: str, version: str) -> None:
|
||||
if not test_pypi_token:
|
||||
print("⚠️ Test PyPI token not provided, skipping Test PyPI upload")
|
||||
return
|
||||
|
||||
PyPIPublisher._upload_to_pypi(
|
||||
token=test_pypi_token,
|
||||
repository="testpypi",
|
||||
display_name="Test PyPI",
|
||||
package_name=package_name,
|
||||
version=version,
|
||||
optional=True,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def publish_to_pypi(pypi_token: str, package_name: str, version: str) -> None:
|
||||
PyPIPublisher._upload_to_pypi(
|
||||
token=pypi_token,
|
||||
repository="pypi",
|
||||
display_name="PyPI",
|
||||
package_name=package_name,
|
||||
version=version,
|
||||
optional=False,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _upload_to_pypi(
|
||||
token: str,
|
||||
repository: str,
|
||||
display_name: str,
|
||||
package_name: str,
|
||||
version: str,
|
||||
optional: bool = False,
|
||||
) -> None:
|
||||
print(f"📦 Publishing to {display_name}...")
|
||||
try:
|
||||
print(f"🔄 Using programmatic twine upload for {display_name}...")
|
||||
|
||||
settings = Settings(
|
||||
username="__token__",
|
||||
password=token,
|
||||
repository=repository,
|
||||
verbose=True,
|
||||
skip_existing=True,
|
||||
disable_progress_bar=False,
|
||||
comment="Automated release via mflux-release-script",
|
||||
)
|
||||
|
||||
dist_files = list(Path("dist").glob("*"))
|
||||
if not dist_files:
|
||||
raise ValueError("No distribution files found in dist/")
|
||||
|
||||
print(f"📦 Uploading {len(dist_files)} files to {display_name}...")
|
||||
for file_path in dist_files:
|
||||
print(f" • {file_path.name}")
|
||||
|
||||
# Retry transient failures (e.g. 5xx replies / connection resets)
|
||||
for attempt in range(3):
|
||||
try:
|
||||
upload.upload(settings, [str(f) for f in dist_files])
|
||||
print(f"✅ Programmatic {display_name} upload completed successfully (attempt {attempt + 1})")
|
||||
break # success → leave retry loop
|
||||
except TwineException as te:
|
||||
transient = any(x in str(te).lower() for x in ["500", "502", "503", "504", "timeout"])
|
||||
if transient and attempt < 2:
|
||||
wait = 2**attempt
|
||||
print(
|
||||
f"⚠️ Transient {display_name} upload error (attempt {attempt + 1}/3): {te}. Retrying in {wait}s"
|
||||
)
|
||||
time.sleep(wait)
|
||||
continue
|
||||
raise # re-raise to outer handler for consistent processing
|
||||
except TwineException as e:
|
||||
error_msg = str(e).lower()
|
||||
if "already exists" in error_msg:
|
||||
print(f"⚠️ Version {version} already exists on {display_name}")
|
||||
return
|
||||
else:
|
||||
# Handle all other TwineException errors consistently
|
||||
print(f"⚠️ {display_name} upload failed: {e}")
|
||||
if "authentication" in error_msg or "403" in error_msg:
|
||||
print(f" This appears to be an authentication issue - check your {display_name} token.")
|
||||
elif "400" in error_msg or "bad request" in error_msg:
|
||||
print(" This might be a partial upload where some files (like wheels) succeeded.")
|
||||
print(" If only wheel uploaded, this is a known issue with source distribution validation.")
|
||||
else:
|
||||
print(" This might be a partial upload where some files succeeded.")
|
||||
print(f" Check {display_name} manually to verify which files were uploaded.")
|
||||
print(f" {display_name} failures are non-critical, continuing with release process")
|
||||
return
|
||||
except (OSError, ValueError, RuntimeError) as e:
|
||||
print(f"⚠️ Unexpected error during {display_name} upload: {e}")
|
||||
print(" This might be a partial upload where some files succeeded.")
|
||||
print(f" Check {display_name} manually to verify which files were uploaded.")
|
||||
print(f" {display_name} failures are non-critical, continuing with release process")
|
||||
return
|
||||
96
src/mflux/release/release_manager.py
Normal file
96
src/mflux/release/release_manager.py
Normal file
@ -0,0 +1,96 @@
|
||||
from typing import Optional
|
||||
|
||||
import requests
|
||||
|
||||
from mflux.release.changelog_parser import ChangelogParser
|
||||
from mflux.release.git_operations import GitOperations
|
||||
from mflux.release.github_api import GitHubAPI
|
||||
from mflux.release.pypi_publisher import PyPIPublisher
|
||||
from mflux.release.release_validator import ReleaseValidator
|
||||
from mflux.utils.version_util import VersionUtil
|
||||
|
||||
|
||||
class ReleaseManager:
|
||||
@staticmethod
|
||||
def create_release(
|
||||
github_token: str,
|
||||
pypi_token: str,
|
||||
test_pypi_token: Optional[str] = None,
|
||||
github_repo: str = "filipstrand/mflux",
|
||||
package_name: str = "mflux",
|
||||
) -> None:
|
||||
# 0. Load version from pyproject.toml
|
||||
version = VersionUtil.get_mflux_version()
|
||||
tag_name = f"v.{version}"
|
||||
print("🚀 Starting MFLUX release process...")
|
||||
print(f"📦 Releasing version: {version} (tag: {tag_name}) [from pyproject.toml]")
|
||||
|
||||
# 1. Validate everything is ready for release
|
||||
ReleaseValidator.validate_release_ready(version)
|
||||
|
||||
# 2. Check current release state
|
||||
git_tag_exists = GitOperations.check_tag_exists(tag_name)
|
||||
github_release_exists = GitHubAPI.check_github_release_exists(github_token, github_repo, tag_name)
|
||||
|
||||
# 3. Print release status and exit early if already complete
|
||||
ReleaseManager._print_release_status(tag_name, git_tag_exists, github_release_exists)
|
||||
if ReleaseManager._is_release_complete(git_tag_exists, github_release_exists):
|
||||
return
|
||||
|
||||
# 4. Create git tag if needed
|
||||
if not git_tag_exists:
|
||||
GitOperations.create_and_push_tag(tag_name, version)
|
||||
|
||||
# 5. Create GitHub release if needed
|
||||
if not github_release_exists:
|
||||
release_notes = ChangelogParser.extract_release_notes_from_changelog(version)
|
||||
GitHubAPI.create_github_release(github_token, github_repo, tag_name, version, release_notes)
|
||||
|
||||
# 6. Handle PyPI publishing
|
||||
if ReleaseManager._should_publish_to_pypi(git_tag_exists, github_release_exists, package_name, version):
|
||||
PyPIPublisher.build_and_verify_package()
|
||||
PyPIPublisher.publish_to_test_pypi(test_pypi_token, package_name, version)
|
||||
PyPIPublisher.publish_to_pypi(pypi_token, package_name, version)
|
||||
|
||||
print(f"🎉 Release process completed successfully for version {version}!")
|
||||
|
||||
@staticmethod
|
||||
def _is_release_complete(git_tag_exists: bool, github_release_exists: bool) -> bool:
|
||||
return git_tag_exists and github_release_exists
|
||||
|
||||
@staticmethod
|
||||
def _is_release_partial(git_tag_exists: bool, github_release_exists: bool) -> bool:
|
||||
return git_tag_exists or github_release_exists
|
||||
|
||||
@staticmethod
|
||||
def _should_publish_to_pypi(
|
||||
git_tag_exists: bool,
|
||||
github_release_exists: bool,
|
||||
package_name: str,
|
||||
version: str,
|
||||
) -> bool:
|
||||
# Only publish if this is a completely new release
|
||||
if ReleaseManager._is_release_partial(git_tag_exists, github_release_exists):
|
||||
print("⚠️ Skipping PyPI publishing since this appears to be a re-run")
|
||||
return False
|
||||
|
||||
# Check if version already exists on PyPI
|
||||
try:
|
||||
if PyPIPublisher.version_exists_on_pypi(package_name, version, test_pypi=False):
|
||||
return False
|
||||
except (requests.RequestException, ValueError, OSError) as e:
|
||||
print(f"❌ Failed to check PyPI version: {e}")
|
||||
raise ValueError(f"PyPI version check failed: {e}") from e
|
||||
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def _print_release_status(tag_name: str, git_tag_exists: bool, github_release_exists: bool) -> None:
|
||||
if ReleaseManager._is_release_complete(git_tag_exists, github_release_exists):
|
||||
print(f"✅ Release {tag_name} already exists completely")
|
||||
print("🔄 This appears to be a re-run of an existing release - nothing to do!")
|
||||
elif ReleaseManager._is_release_partial(git_tag_exists, github_release_exists):
|
||||
print("⚠️ Partial release state detected:")
|
||||
print(f" Git tag exists: {git_tag_exists}")
|
||||
print(f" GitHub release exists: {github_release_exists}")
|
||||
print(" Will complete the missing parts...")
|
||||
32
src/mflux/release/release_script.py
Normal file
32
src/mflux/release/release_script.py
Normal file
@ -0,0 +1,32 @@
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
|
||||
import requests
|
||||
|
||||
from mflux.release.release_manager import ReleaseManager
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="MFLUX Release Management")
|
||||
parser.add_argument("--github-token", help="GitHub token")
|
||||
parser.add_argument("--pypi-token", help="PyPI API token")
|
||||
parser.add_argument("--test-pypi-token", help="Test PyPI API token (optional)")
|
||||
args = parser.parse_args()
|
||||
|
||||
try:
|
||||
ReleaseManager.create_release(
|
||||
github_token=args.github_token or os.getenv("GITHUB_TOKEN"),
|
||||
pypi_token=args.pypi_token or os.getenv("PYPI_API_TOKEN"),
|
||||
test_pypi_token=args.test_pypi_token or os.getenv("TEST_PYPI_API_TOKEN"),
|
||||
)
|
||||
except (ValueError, FileNotFoundError, requests.RequestException) as e:
|
||||
print(f"❌ Release failed: {e}")
|
||||
sys.exit(1)
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"❌ Release failed with unexpected error: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
87
src/mflux/release/release_validator.py
Normal file
87
src/mflux/release/release_validator.py
Normal file
@ -0,0 +1,87 @@
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
|
||||
from .changelog_parser import ChangelogParser
|
||||
|
||||
|
||||
class ReleaseValidator:
|
||||
@staticmethod
|
||||
def validate_release_ready(version: str) -> None:
|
||||
print("🔍 Validating release readiness...")
|
||||
ReleaseValidator._validate_version_format(version)
|
||||
ReleaseValidator._validate_changelog_format()
|
||||
ReleaseValidator._validate_changelog_entry(version)
|
||||
ReleaseValidator._validate_branch()
|
||||
ReleaseValidator._validate_uncommitted_changes()
|
||||
print(f"✅ Release validation passed for version {version}")
|
||||
|
||||
@staticmethod
|
||||
def _validate_version_format(version: str) -> None:
|
||||
if not re.match(r"^\d+\.\d+\.\d+", version):
|
||||
raise ValueError(f"Version format appears invalid: {version}")
|
||||
|
||||
@staticmethod
|
||||
def _validate_changelog_entry(version: str) -> None:
|
||||
try:
|
||||
ChangelogParser.extract_release_notes_from_changelog(version)
|
||||
print(f"✅ Changelog entry found for version {version}")
|
||||
|
||||
# Validate version consistency
|
||||
latest_changelog_version = ChangelogParser.get_latest_version()
|
||||
if version != latest_changelog_version:
|
||||
raise ValueError(
|
||||
f"Version mismatch: pyproject.toml has version '{version}' but "
|
||||
f"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}")
|
||||
|
||||
@staticmethod
|
||||
def _validate_branch() -> None:
|
||||
current_branch = os.getenv("GITHUB_REF_NAME") or os.getenv("GITHUB_HEAD_REF")
|
||||
|
||||
if not current_branch:
|
||||
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})")
|
||||
|
||||
@staticmethod
|
||||
def _validate_changelog_format() -> None:
|
||||
try:
|
||||
issues = ChangelogParser.validate_changelog_format()
|
||||
if issues:
|
||||
print("❌ Changelog format validation failed:")
|
||||
for issue in issues:
|
||||
print(f" • {issue}")
|
||||
raise ValueError(f"Changelog format validation failed with {len(issues)} issues")
|
||||
print("✅ Changelog format validation passed")
|
||||
except ValueError as e:
|
||||
if "Changelog format validation failed" in str(e):
|
||||
raise # Re-raise our own validation error
|
||||
else:
|
||||
raise ValueError(f"Changelog format validation failed: {e}")
|
||||
|
||||
@staticmethod
|
||||
def _validate_uncommitted_changes() -> None:
|
||||
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")
|
||||
0
src/mflux/utils/__init__.py
Normal file
0
src/mflux/utils/__init__.py
Normal file
32
src/mflux/utils/version_util.py
Normal file
32
src/mflux/utils/version_util.py
Normal file
@ -0,0 +1,32 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.metadata
|
||||
from pathlib import Path
|
||||
|
||||
import toml
|
||||
|
||||
|
||||
class VersionUtil:
|
||||
@staticmethod
|
||||
def get_mflux_version() -> str:
|
||||
return VersionUtil._scan_pyproject() or VersionUtil._get_installed_version() or "unknown"
|
||||
|
||||
@staticmethod
|
||||
def _scan_pyproject() -> str | None:
|
||||
current_dir = Path(__file__).resolve().parent
|
||||
for parent in current_dir.parents:
|
||||
pyproject_path = parent / "pyproject.toml"
|
||||
if pyproject_path.exists():
|
||||
try:
|
||||
data = toml.load(pyproject_path)
|
||||
return data.get("project", {}).get("version")
|
||||
except Exception: # noqa: BLE001
|
||||
return None
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _get_installed_version() -> str | None:
|
||||
try:
|
||||
return importlib.metadata.version("mflux")
|
||||
except importlib.metadata.PackageNotFoundError:
|
||||
return None
|
||||
@ -5,7 +5,7 @@ from mlx import nn
|
||||
from mlx.utils import tree_flatten
|
||||
from transformers import CLIPTokenizer, T5Tokenizer
|
||||
|
||||
from mflux.post_processing.generated_image import GeneratedImage
|
||||
from mflux.utils.version_util import VersionUtil
|
||||
|
||||
|
||||
class ModelSaver:
|
||||
@ -38,7 +38,7 @@ class ModelSaver:
|
||||
weight,
|
||||
{
|
||||
"quantization_level": str(bits),
|
||||
"mflux_version": GeneratedImage.get_version(),
|
||||
"mflux_version": VersionUtil.get_mflux_version(),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@ -5,7 +5,7 @@ from pathlib import Path
|
||||
import numpy as np
|
||||
|
||||
from mflux import Config, Flux1, ModelConfig
|
||||
from mflux.post_processing.generated_image import GeneratedImage
|
||||
from mflux.utils.version_util import VersionUtil
|
||||
from mflux.weights.weight_handler import WeightHandler
|
||||
|
||||
PATH = "tests/4bit/"
|
||||
@ -36,7 +36,7 @@ class TestModelSaving:
|
||||
|
||||
# Verify that the mflux version is correctly saved in the model's metadata
|
||||
_, quantization_level, mflux_version = WeightHandler._load_vae(root_path=Path(PATH))
|
||||
assert mflux_version == GeneratedImage.get_version(), "mflux version not correctly saved in metadata" # fmt: off
|
||||
assert mflux_version == VersionUtil.get_mflux_version(), "mflux version not correctly saved in metadata" # fmt: off
|
||||
assert quantization_level == "4", "quantization level not correctly saved in metadata" # fmt: off
|
||||
|
||||
# when loading the quantized model (also without specifying bits)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user