diff --git a/.bumpversion.toml b/.bumpversion.toml new file mode 100644 index 0000000..44fc771 --- /dev/null +++ b/.bumpversion.toml @@ -0,0 +1,29 @@ +[tool.bumpversion] +current_version = "0.0.1" +parse = "(?P\\d+)\\.(?P\\d+)\\.(?P\\d+)" +serialize = ["{major}.{minor}.{patch}"] +search = "{current_version}" +replace = "{new_version}" +regex = false +ignore_missing_files = false +ignore_missing_version = false +tag = false +sign_tags = false +tag_name = "v{new_version}" +tag_message = "Release version {new_version}" +allow_dirty = false +commit = false +message = "chore: bump version {current_version} → {new_version}" + +# pyproject.toml - project version +[[tool.bumpversion.files]] +filename = "pyproject.toml" +search = 'version = "{current_version}"' +replace = 'version = "{new_version}"' + +# lance_ray/__init__.py - package version (if exists) +[[tool.bumpversion.files]] +filename = "lance_ray/__init__.py" +search = '__version__ = "{current_version}"' +replace = '__version__ = "{new_version}"' +ignore_missing_files = true \ No newline at end of file diff --git a/.github/release.yml b/.github/release.yml new file mode 100644 index 0000000..9b3e775 --- /dev/null +++ b/.github/release.yml @@ -0,0 +1,36 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +changelog: + exclude: + labels: + - ci + - chore + categories: + - title: Breaking Changes 🛠 + labels: + - breaking-change + - title: New Features 🎉 + labels: + - enhancement + - title: Bug Fixes 🐛 + labels: + - bug + - title: Documentation 📚 + labels: + - documentation + - title: Performance Improvements 🚀 + labels: + - performance + - title: Other Changes + labels: + - "*" diff --git a/.github/workflows/auto-bump.yml b/.github/workflows/auto-bump.yml new file mode 100644 index 0000000..e2c1af1 --- /dev/null +++ b/.github/workflows/auto-bump.yml @@ -0,0 +1,189 @@ +name: Auto Bump Version + +on: + workflow_dispatch: + inputs: + bump_type: + description: 'Type of version bump' + required: false + default: 'auto' + type: choice + options: + - auto + - patch + - minor + - major + +jobs: + check-for-changes: + runs-on: ubuntu-latest + outputs: + should_bump: ${{ steps.check.outputs.should_bump }} + bump_type: ${{ steps.check.outputs.bump_type }} + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Check for unreleased changes + id: check + run: | + # Get the last tag + LAST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "") + + if [ -z "$LAST_TAG" ]; then + echo "No tags found, should create initial release" + echo "should_bump=true" >> $GITHUB_OUTPUT + echo "bump_type=patch" >> $GITHUB_OUTPUT + exit 0 + fi + + # Check for commits since last tag + COMMITS_SINCE_TAG=$(git rev-list --count ${LAST_TAG}..HEAD) + + if [ "$COMMITS_SINCE_TAG" -gt 0 ]; then + echo "Found $COMMITS_SINCE_TAG commits since last tag $LAST_TAG" + + # Determine bump type based on input or commit analysis + if [ "${{ inputs.bump_type }}" != "auto" ] && [ -n "${{ inputs.bump_type }}" ]; then + # Use manual input if provided and not "auto" + BUMP_TYPE="${{ inputs.bump_type }}" + else + # Analyze commit messages to determine bump type + BUMP_TYPE="patch" + + # Check for breaking changes (major bump) + if git log ${LAST_TAG}..HEAD --grep="BREAKING CHANGE" --grep="!:" | grep -q .; then + BUMP_TYPE="major" + # Check for features (minor bump) + elif git log ${LAST_TAG}..HEAD --grep="^feat" --grep="^feature" | grep -q .; then + BUMP_TYPE="minor" + fi + fi + + echo "should_bump=true" >> $GITHUB_OUTPUT + echo "bump_type=$BUMP_TYPE" >> $GITHUB_OUTPUT + else + echo "No commits since last tag $LAST_TAG" + echo "should_bump=false" >> $GITHUB_OUTPUT + fi + + - name: Summary + run: | + echo "## Auto Bump Check" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + if [ "${{ steps.check.outputs.should_bump }}" == "true" ]; then + echo "✅ Version bump needed" >> $GITHUB_STEP_SUMMARY + echo "- **Bump Type:** ${{ steps.check.outputs.bump_type }}" >> $GITHUB_STEP_SUMMARY + else + echo "⏭️ No version bump needed" >> $GITHUB_STEP_SUMMARY + fi + + create-bump-pr: + needs: check-for-changes + if: needs.check-for-changes.outputs.should_bump == 'true' + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + token: ${{ secrets.GITHUB_TOKEN }} + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install dependencies + run: | + pip install packaging toml bump-my-version + + - name: Get current version + id: current_version + run: | + CURRENT_VERSION=$(python -c "import toml; print(toml.load('pyproject.toml')['project']['version'])") + echo "version=$CURRENT_VERSION" >> $GITHUB_OUTPUT + echo "Current version: $CURRENT_VERSION" + + - name: Calculate new version + id: new_version + run: | + python ci/calculate_version.py \ + --current "${{ steps.current_version.outputs.version }}" \ + --type "${{ needs.check-for-changes.outputs.bump_type }}" \ + --channel "stable" + + - name: Create feature branch + run: | + BRANCH_NAME="auto-bump-${{ steps.new_version.outputs.version }}" + git checkout -b $BRANCH_NAME + echo "branch=$BRANCH_NAME" >> $GITHUB_ENV + + - name: Bump version + run: | + python ci/bump_version.py --version "${{ steps.new_version.outputs.version }}" + + - name: Configure git + run: | + git config user.name 'github-actions[bot]' + git config user.email 'github-actions[bot]@users.noreply.github.com' + + - name: Commit changes + run: | + git add -A + git commit -m "chore: bump version to ${{ steps.new_version.outputs.version }} + + Automated version bump from ${{ steps.current_version.outputs.version }} to ${{ steps.new_version.outputs.version }}. + Bump type: ${{ needs.check-for-changes.outputs.bump_type }}" + + - name: Push changes + run: | + git push origin ${{ env.branch }} + + - name: Create Pull Request + uses: peter-evans/create-pull-request@v5 + with: + token: ${{ secrets.GITHUB_TOKEN }} + branch: ${{ env.branch }} + base: main + title: "chore: bump version to ${{ steps.new_version.outputs.version }}" + body: | + ## Automated Version Bump + + This PR automatically bumps the version from `${{ steps.current_version.outputs.version }}` to `${{ steps.new_version.outputs.version }}`. + + ### Details + - **Bump Type:** ${{ needs.check-for-changes.outputs.bump_type }} + - **Triggered By:** ${{ github.event_name == 'workflow_dispatch' && 'Manual trigger' || 'Automated' }} + + ### Checklist + - [ ] Review version bump changes + - [ ] Verify pyproject.toml is updated + - [ ] Confirm CI checks pass + + ### Next Steps + After merging this PR, you can create a release by: + 1. Going to Actions → Create Release workflow + 2. Selecting the release channel (stable/preview) + 3. Running the workflow + + --- + *This PR was automatically generated by the auto-bump workflow.* + labels: | + version-bump + automated + assignees: ${{ github.actor }} + + - name: Summary + run: | + echo "## Version Bump PR Created" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "- **Current Version:** ${{ steps.current_version.outputs.version }}" >> $GITHUB_STEP_SUMMARY + echo "- **New Version:** ${{ steps.new_version.outputs.version }}" >> $GITHUB_STEP_SUMMARY + echo "- **Bump Type:** ${{ needs.check-for-changes.outputs.bump_type }}" >> $GITHUB_STEP_SUMMARY + echo "- **Branch:** ${{ env.branch }}" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "✅ Pull request created successfully!" >> $GITHUB_STEP_SUMMARY \ No newline at end of file diff --git a/.github/workflows/python-release.yml b/.github/workflows/python-release.yml index 4c8be22..8b8fa5d 100644 --- a/.github/workflows/python-release.yml +++ b/.github/workflows/python-release.yml @@ -29,6 +29,10 @@ on: options: - dry_run - release + ref: + description: 'The branch, tag or SHA to checkout' + required: false + type: string jobs: publish: @@ -39,6 +43,10 @@ jobs: steps: - name: Checkout code uses: actions/checkout@v4 + with: + # When triggered by a release, use the release tag + # When triggered manually, use the provided ref + ref: ${{ github.event.release.tag_name || inputs.ref }} - name: Set up Python uses: actions/setup-python@v5 @@ -52,9 +60,17 @@ jobs: run: | uv build + - name: Get package version + id: get_version + run: | + VERSION=$(python -c "import tomllib; print(tomllib.load(open('pyproject.toml', 'rb'))['project']['version'])") + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "Package version: $VERSION" + - name: Publish to PyPI if: | (github.event_name == 'release' && github.event.action == 'released') || (github.event_name == 'workflow_dispatch' && github.event.inputs.mode == 'release') run: | - uv publish --trusted-publishing always \ No newline at end of file + uv publish --trusted-publishing always + echo "✅ Successfully published version ${{ steps.get_version.outputs.version }} to PyPI!" \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..a8b4adc --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,172 @@ +name: Create Release + +on: + workflow_dispatch: + inputs: + release_type: + description: 'Release type' + required: true + default: 'patch' + type: choice + options: + - patch + - minor + - major + release_channel: + description: 'Release channel' + required: true + default: 'preview' + type: choice + options: + - preview + - stable + dry_run: + description: 'Dry run (simulate the release without pushing)' + required: true + default: true + type: boolean + +jobs: + create-release: + runs-on: ubuntu-latest + steps: + - name: Output Inputs + run: echo "${{ toJSON(github.event.inputs) }}" + + - name: Checkout repository + uses: actions/checkout@v4 + with: + ref: main + token: ${{ secrets.GITHUB_TOKEN }} + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install dependencies + run: | + pip install packaging toml bump-my-version + + - name: Get current version + id: current_version + run: | + CURRENT_VERSION=$(python -c "import toml; print(toml.load('pyproject.toml')['project']['version'])") + echo "version=$CURRENT_VERSION" >> $GITHUB_OUTPUT + echo "Current version: $CURRENT_VERSION" + + - name: Calculate new version + id: new_version + run: | + python ci/calculate_version.py \ + --current "${{ steps.current_version.outputs.version }}" \ + --type "${{ inputs.release_type }}" \ + --channel "${{ inputs.release_channel }}" + + - name: Determine tag name + id: tag_name + run: | + if [ "${{ inputs.release_channel }}" == "stable" ]; then + VERSION="${{ steps.new_version.outputs.version }}" + TAG="v${VERSION}" + else + # For preview releases, use current version with beta suffix + VERSION="${{ steps.current_version.outputs.version }}" + # Find the next beta number for current version + BETA_TAGS=$(git tag -l "v${VERSION}-beta.*" | sort -V) + if [ -z "$BETA_TAGS" ]; then + BETA_NUM=1 + else + LAST_BETA=$(echo "$BETA_TAGS" | tail -n 1) + LAST_NUM=$(echo "$LAST_BETA" | sed "s/v${VERSION}-beta.//") + BETA_NUM=$((LAST_NUM + 1)) + fi + TAG="v${VERSION}-beta.${BETA_NUM}" + fi + + echo "tag=$TAG" >> $GITHUB_OUTPUT + echo "Tag will be: $TAG" + + - name: Update version (stable releases only) + if: inputs.release_channel == 'stable' + run: | + python ci/bump_version.py --version "${{ steps.new_version.outputs.version }}" + + - name: Configure git identity + run: | + git config user.name 'github-actions[bot]' + git config user.email 'github-actions[bot]@users.noreply.github.com' + + - name: Create release commit (stable releases only) + if: inputs.release_channel == 'stable' + run: | + git add -A + git commit -m "chore: release version ${{ steps.new_version.outputs.version }}" || echo "No changes to commit" + + - name: Create tag + run: | + git tag -a "${{ steps.tag_name.outputs.tag }}" -m "Release ${{ steps.tag_name.outputs.tag }}" + + - name: Push changes (if not dry run) + if: ${{ !inputs.dry_run }} + run: | + if [ "${{ inputs.release_channel }}" == "stable" ]; then + # Push the commit for stable releases + git push origin main + fi + # Always push the tag + git push origin "${{ steps.tag_name.outputs.tag }}" + + - name: Generate release notes + id: release_notes + if: ${{ !inputs.dry_run }} + run: | + python ci/generate_release_notes.py \ + --tag "${{ steps.tag_name.outputs.tag }}" \ + --repo "${{ github.repository }}" \ + --token "${{ secrets.GITHUB_TOKEN }}" + + - name: Create GitHub Release Draft (if not dry run) + if: ${{ !inputs.dry_run }} + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ steps.tag_name.outputs.tag }} + name: ${{ steps.tag_name.outputs.tag }} + body_path: release_notes.md + draft: true + prerelease: ${{ inputs.release_channel == 'preview' }} + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Summary + run: | + echo "## Release Summary" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "- **Release Type:** ${{ inputs.release_type }}" >> $GITHUB_STEP_SUMMARY + echo "- **Release Channel:** ${{ inputs.release_channel }}" >> $GITHUB_STEP_SUMMARY + echo "- **Current Version:** ${{ steps.current_version.outputs.version }}" >> $GITHUB_STEP_SUMMARY + if [ "${{ inputs.release_channel }}" == "stable" ]; then + echo "- **New Version:** ${{ steps.new_version.outputs.version }}" >> $GITHUB_STEP_SUMMARY + fi + echo "- **Tag:** ${{ steps.tag_name.outputs.tag }}" >> $GITHUB_STEP_SUMMARY + echo "- **Dry Run:** ${{ inputs.dry_run }}" >> $GITHUB_STEP_SUMMARY + + if [ "${{ inputs.dry_run }}" == "true" ]; then + echo "" >> $GITHUB_STEP_SUMMARY + echo "⚠️ This was a dry run. No changes were pushed." >> $GITHUB_STEP_SUMMARY + else + echo "" >> $GITHUB_STEP_SUMMARY + echo "📝 Draft release created successfully!" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "### Next Steps:" >> $GITHUB_STEP_SUMMARY + echo "1. Review the draft release on the [releases page](https://github.com/${{ github.repository }}/releases)" >> $GITHUB_STEP_SUMMARY + echo "2. Edit the release notes if needed" >> $GITHUB_STEP_SUMMARY + echo "3. Publish the release to:" >> $GITHUB_STEP_SUMMARY + if [ "${{ inputs.release_channel }}" == "stable" ]; then + echo " - Create the official release" >> $GITHUB_STEP_SUMMARY + echo " - Trigger automatic publishing to PyPI" >> $GITHUB_STEP_SUMMARY + else + echo " - Create a preview/beta release" >> $GITHUB_STEP_SUMMARY + echo " - Note: Preview releases are not published to PyPI by default" >> $GITHUB_STEP_SUMMARY + fi + fi \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2351890..9860b2d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -74,3 +74,29 @@ uv run mkdocs serve # Documentation will be available at http://localhost:8000 ``` + +## Release Process + +This section describes the CI/CD workflows for automated version management, releases, and publishing. + +### Version Scheme + +- **Stable releases:** `X.Y.Z` (e.g., 1.2.3) +- **Preview releases:** `X.Y.Z-beta.N` (e.g., 1.2.3-beta.1) + +### Creating a Release + +1. **Create Release Draft** + - Go to Actions → "Create Release" + - Select parameters: + - Release type (major/minor/patch) + - Release channel (stable/preview) + - Dry run (test without pushing) + - Run workflow (creates a draft release) + +2. **Review and Publish** + - Go to the [Releases page](../../releases) to review the draft + - Edit release notes if needed + - Click "Publish release" to: + - For stable releases: Trigger automatic PyPI publishing + - For preview releases: Create a beta release (not published to PyPI) diff --git a/ci/bump_version.py b/ci/bump_version.py new file mode 100644 index 0000000..ddf7d19 --- /dev/null +++ b/ci/bump_version.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +""" +Version management script for Lance Ray project. +Uses bump-my-version to handle version bumping across all project components. +""" + +import argparse +import subprocess +import sys +from pathlib import Path + + +def run_command(cmd: list[str], capture_output: bool = True) -> subprocess.CompletedProcess: + """Run a command and return the result.""" + print(f"Running: {' '.join(cmd)}") + result = subprocess.run(cmd, capture_output=capture_output, text=True) + if result.returncode != 0: + print(f"Error running command: {' '.join(cmd)}") + if capture_output: + print(f"stderr: {result.stderr}") + sys.exit(result.returncode) + return result + + +def get_current_version() -> str: + """Get the current version from .bumpversion.toml.""" + config_path = Path(".bumpversion.toml") + if not config_path.exists(): + raise FileNotFoundError(".bumpversion.toml not found in current directory") + + with open(config_path) as f: + for line in f: + if line.strip().startswith('current_version = "'): + return line.split('"')[1] + raise ValueError("Could not find current_version in .bumpversion.toml") + + +def main(): + parser = argparse.ArgumentParser(description='Bump version in Python project using bump-my-version') + parser.add_argument('--version', required=True, help='New version to set') + parser.add_argument('--dry-run', action='store_true', help='Show what would be changed without making changes') + + args = parser.parse_args() + + # Get current version + current_version = get_current_version() + new_version = args.version + + print(f"Current version: {current_version}") + print(f"New version: {new_version}") + + if args.dry_run: + print("\nDry run mode - no changes will be made") + # Run bump-my-version in dry-run mode + cmd = ["bump-my-version", "bump", "--current-version", current_version, + "--new-version", new_version, "--dry-run", "--verbose", "--allow-dirty"] + run_command(cmd, capture_output=False) + else: + # Use bump-my-version to update all files + print("\nUpdating version in all files...") + cmd = ["bump-my-version", "bump", "--current-version", current_version, + "--new-version", new_version, "--no-commit", "--no-tag", "--allow-dirty"] + run_command(cmd) + print(f"\nSuccessfully updated version from {current_version} to {new_version}") + + +if __name__ == '__main__': + main() diff --git a/ci/calculate_version.py b/ci/calculate_version.py new file mode 100644 index 0000000..1b1f9b3 --- /dev/null +++ b/ci/calculate_version.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python3 +""" +Script to calculate the next version based on release type +""" + +import argparse +import os +import sys + +from packaging import version + + +def calculate_next_version(current_version, release_type, channel): + """Calculate the next version based on release type and channel""" + + # Parse current version + v = version.parse(current_version) + + # Extract major, minor, patch + if hasattr(v, 'release'): + major, minor, patch = v.release[:3] if len(v.release) >= 3 else (*v.release, 0, 0)[:3] + else: + # Fallback for simple versions + parts = current_version.split('.') + major = int(parts[0]) if len(parts) > 0 else 0 + minor = int(parts[1]) if len(parts) > 1 else 0 + patch = int(parts[2]) if len(parts) > 2 else 0 + + # Calculate new version for stable releases + if channel == 'stable': + if release_type == 'major': + new_version = f"{major + 1}.0.0" + elif release_type == 'minor': + new_version = f"{major}.{minor + 1}.0" + elif release_type == 'patch': + new_version = f"{major}.{minor}.{patch + 1}" + else: + raise ValueError(f"Unknown release type: {release_type}") + else: + # For preview releases, keep the current version + new_version = current_version + + return new_version + +def main(): + parser = argparse.ArgumentParser(description='Calculate next version') + parser.add_argument('--current', required=True, help='Current version') + parser.add_argument('--type', required=True, choices=['major', 'minor', 'patch'], help='Release type') + parser.add_argument('--channel', required=True, choices=['stable', 'preview'], help='Release channel') + + args = parser.parse_args() + + try: + new_version = calculate_next_version(args.current, args.type, args.channel) + + # Output for GitHub Actions + print(f"version={new_version}") + + # Also write to GITHUB_OUTPUT if available + github_output = os.environ.get('GITHUB_OUTPUT') + if github_output: + with open(github_output, 'a') as f: + f.write(f"version={new_version}\n") + + except Exception as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) + +if __name__ == '__main__': + main() diff --git a/ci/generate_release_notes.py b/ci/generate_release_notes.py new file mode 100644 index 0000000..7f69b99 --- /dev/null +++ b/ci/generate_release_notes.py @@ -0,0 +1,228 @@ +#!/usr/bin/env python3 +""" +Script to generate release notes from git commits and pull requests +""" + +import argparse +import json +import os +import re +import subprocess +import sys +import urllib.error +import urllib.request +from datetime import datetime + + +def get_github_api_data(url, token): + """Fetch data from GitHub API""" + headers = { + 'Authorization': f'token {token}', + 'Accept': 'application/vnd.github.v3+json' + } + + req = urllib.request.Request(url, headers=headers) + try: + with urllib.request.urlopen(req) as response: + return json.loads(response.read().decode()) + except urllib.error.HTTPError as e: + print(f"Error accessing GitHub API: {e}", file=sys.stderr) + return None + +def get_commits_since_last_tag(tag): + """Get all commits since the last tag""" + try: + # Get the previous tag + tags = subprocess.check_output(['git', 'tag', '--sort=-version:refname']).decode().strip().split('\n') + + current_tag_index = tags.index(tag) if tag in tags else 0 + previous_tag = tags[current_tag_index + 1] if current_tag_index + 1 < len(tags) else None + + # Get commit range + commit_range = f"{previous_tag}..{tag}" if previous_tag else tag + + # Get commit messages + commits = subprocess.check_output([ + 'git', 'log', commit_range, + '--pretty=format:%H|%s|%an|%ae', + '--no-merges' + ]).decode().strip() + + if not commits: + return [], previous_tag + + commit_list = [] + for line in commits.split('\n'): + if line: + parts = line.split('|') + if len(parts) >= 4: + commit_list.append({ + 'sha': parts[0], + 'message': parts[1], + 'author': parts[2], + 'email': parts[3] + }) + + return commit_list, previous_tag + + except subprocess.CalledProcessError as e: + print(f"Error getting commits: {e}", file=sys.stderr) + return [], None + +def categorize_commits(commits): + """Categorize commits based on conventional commit format""" + categories = { + 'Features': [], + 'Bug Fixes': [], + 'Performance': [], + 'Documentation': [], + 'Refactoring': [], + 'Tests': [], + 'Chores': [], + 'Other': [] + } + + for commit in commits: + message = commit['message'] + + # Parse conventional commit format + if message.startswith('feat:') or message.startswith('feature:'): + categories['Features'].append(commit) + elif message.startswith('fix:') or message.startswith('bugfix:'): + categories['Bug Fixes'].append(commit) + elif message.startswith('perf:') or message.startswith('performance:'): + categories['Performance'].append(commit) + elif message.startswith('docs:') or message.startswith('doc:'): + categories['Documentation'].append(commit) + elif message.startswith('refactor:'): + categories['Refactoring'].append(commit) + elif message.startswith('test:') or message.startswith('tests:'): + categories['Tests'].append(commit) + elif message.startswith('chore:') or message.startswith('ci:') or message.startswith('build:'): + categories['Chores'].append(commit) + else: + categories['Other'].append(commit) + + return categories + +def extract_pr_number(message): + """Extract PR number from commit message""" + # Look for patterns like (#123) or #123 + match = re.search(r'#(\d+)', message) + if match: + return match.group(1) + return None + +def generate_release_notes(tag, repo, token, commits, previous_tag): + """Generate release notes in Markdown format""" + + notes = [] + notes.append(f"# Release {tag}") + notes.append("") + notes.append(f"Released on {datetime.now().strftime('%Y-%m-%d')}") + notes.append("") + + if previous_tag: + notes.append(f"## Changes since {previous_tag}") + else: + notes.append("## Changes") + notes.append("") + + # Categorize commits + categories = categorize_commits(commits) + + # Add categorized commits to release notes + for category, category_commits in categories.items(): + if category_commits: + notes.append(f"### {category}") + notes.append("") + + for commit in category_commits: + message = commit['message'] + sha = commit['sha'][:7] + + # Clean up the message (remove prefix) + cleaned_message = re.sub(r'^(feat|fix|docs|style|refactor|perf|test|chore|ci|build):\s*', '', message, flags=re.IGNORECASE) + + # Extract PR number if present + pr_number = extract_pr_number(message) + + if pr_number and token: + # Try to get PR information from GitHub API + pr_url = f"https://api.github.com/repos/{repo}/pulls/{pr_number}" + pr_data = get_github_api_data(pr_url, token) + + if pr_data: + pr_author = pr_data.get('user', {}).get('login', commit['author']) + pr_title = pr_data.get('title', cleaned_message) + notes.append(f"- {pr_title} (#{pr_number}) by @{pr_author}") + else: + notes.append(f"- {cleaned_message} ({sha}) by {commit['author']}") + else: + notes.append(f"- {cleaned_message} ({sha}) by {commit['author']}") + + notes.append("") + + # Add contributors section + contributors = set() + for commit in commits: + contributors.add(commit['author']) + + if contributors: + notes.append("## Contributors") + notes.append("") + notes.append("Thanks to the following contributors for this release:") + notes.append("") + for contributor in sorted(contributors): + notes.append(f"- {contributor}") + notes.append("") + + # Add footer + notes.append("---") + notes.append("") + notes.append("For more details, see the [full changelog](https://github.com/{}/compare/{}...{})".format( + repo, + previous_tag if previous_tag else 'main', + tag + )) + + return '\n'.join(notes) + +def main(): + parser = argparse.ArgumentParser(description='Generate release notes') + parser.add_argument('--tag', required=True, help='Release tag') + parser.add_argument('--repo', required=True, help='GitHub repository (owner/repo)') + parser.add_argument('--token', help='GitHub token for API access') + parser.add_argument('--output', default='release_notes.md', help='Output file (default: release_notes.md)') + + args = parser.parse_args() + + # Get commits since last tag + commits, previous_tag = get_commits_since_last_tag(args.tag) + + if not commits: + print("Warning: No commits found for this release", file=sys.stderr) + commits = [] + + # Generate release notes + release_notes = generate_release_notes( + args.tag, + args.repo, + args.token, + commits, + previous_tag + ) + + # Write to file + with open(args.output, 'w') as f: + f.write(release_notes) + + print(f"Release notes written to {args.output}") + + # Also output to stdout for debugging + if os.environ.get('GITHUB_ACTIONS'): + print("\n--- Release Notes ---") + print(release_notes) + +if __name__ == '__main__': + main()