diff --git a/.github/workflows/pr-automerge.yml b/.github/workflows/pr-automerge.yml new file mode 100644 index 0000000..4b4e571 --- /dev/null +++ b/.github/workflows/pr-automerge.yml @@ -0,0 +1,66 @@ +name: PR Auto-merge + +on: + pull_request_review: + types: [submitted] + +permissions: + contents: write + pull-requests: write + +jobs: + auto-merge: + name: Auto-merge Release PRs + runs-on: ubuntu-latest + # Only run when PR is approved and it's a release PR + if: | + github.event.review.state == 'approved' && + github.event.pull_request.user.login == 'github-actions[bot]' && + startsWith(github.event.pull_request.head.ref, 'release/') && + github.event.pull_request.base.ref == 'main' + + steps: + - name: Check CI status + id: ci-status + uses: actions/github-script@v7 + with: + script: | + const { data: checkRuns } = await github.rest.checks.listForRef({ + owner: context.repo.owner, + repo: context.repo.repo, + ref: context.payload.pull_request.head.sha + }); + + // Include ALL required checks + const requiredChecks = [ + 'Lint', + 'Test Python 3.10', + 'Test Python 3.11', + 'Test Python 3.12', + 'Test Python 3.13', + 'Build Package' + ]; + + const allPassed = requiredChecks.every(checkName => { + const check = checkRuns.check_runs.find(run => run.name === checkName); + return check && check.conclusion === 'success'; + }); + + console.log(`All required checks passed: ${allPassed}`); + return allPassed; + + - name: Auto-merge PR + if: steps.ci-status.outputs.result == 'true' + uses: actions/github-script@v7 + with: + script: | + await github.rest.pulls.merge({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.payload.pull_request.number, + merge_method: 'squash', + commit_title: `Release v${context.payload.pull_request.head.ref.split('/')[1]}`, + commit_message: 'Auto-merged by release workflow' + }); + + console.log('✓ PR auto-merged successfully'); diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7ca0310..8ce3637 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,30 +1,176 @@ -name: Release to PyPI +name: Release on: - push: - tags: - - 'v*' workflow_dispatch: inputs: - test_release: - description: 'Test release (TestPyPI only)' + bump_type: + description: 'Version bump type' required: true - default: 'true' type: choice options: - - 'true' - - 'false' + - patch + - minor + - major + - pre + changelog: + description: 'Custom changelog entry (optional - leave empty to auto-generate)' + required: false + type: string permissions: contents: write - id-token: write + pull-requests: write jobs: - build: - name: Build Release + prepare-release: + name: Prepare Release runs-on: ubuntu-latest + outputs: + version: ${{ steps.bump.outputs.version }} + + steps: + - name: Validate running from main + run: | + if [[ "${{ github.ref }}" != "refs/heads/main" ]]; then + echo "⚠️ WARNING: Running from ${{ github.ref }}" + echo "⚠️ Production releases should only run from main branch" + fi + + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.10' + + - name: Configure git + run: | + git config --global user.name "github-actions[bot]" + git config --global user.email "github-actions[bot]@users.noreply.github.com" + + - name: Get current version + id: current + run: | + VERSION=$(grep -m1 -oP '^version = "\K[^"]+' pyproject.toml) + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "Current version: $VERSION" + + - name: Bump version + id: bump + run: | + chmod +x scripts/bump_version.py + + # If no custom changelog, provide guidance + if [ -z "${{ github.event.inputs.changelog }}" ]; then + echo "ℹ️ No custom changelog provided. Will auto-generate from commits." + echo "💡 Tip: Provide a meaningful changelog message for better release notes" + fi + + if [ -n "${{ github.event.inputs.changelog }}" ]; then + python scripts/bump_version.py ${{ github.event.inputs.bump_type }} \ + --changelog "${{ github.event.inputs.changelog }}" + else + python scripts/bump_version.py ${{ github.event.inputs.bump_type }} + fi + + uv lock --no-progress + + NEW_VERSION=$(grep -m1 -oP '^version = "\K[^"]+' pyproject.toml) + echo "version=$NEW_VERSION" >> $GITHUB_OUTPUT + echo "New version: $NEW_VERSION" + + - name: Create release branch and PR + run: | + BRANCH_NAME="release/v${{ steps.bump.outputs.version }}" + + if git ls-remote --exit-code --heads origin $BRANCH_NAME; then + echo "⚠️ Branch $BRANCH_NAME already exists. Deleting it first..." + git push origin --delete $BRANCH_NAME + fi + + if git show-ref --verify --quiet refs/heads/$BRANCH_NAME; then + git branch -D $BRANCH_NAME + fi + + git checkout -b $BRANCH_NAME + git add -A + git commit -m "chore: bump version to ${{ steps.bump.outputs.version }} + + Co-authored-by: github-actions[bot] " + + git push origin $BRANCH_NAME + + COMMITTED_VERSION=$(git show HEAD:pyproject.toml | grep -m1 -oP '^version = "\K[^"]+') + if [ "$COMMITTED_VERSION" != "${{ steps.bump.outputs.version }}" ]; then + echo "❌ ERROR: Version not committed correctly!" + exit 1 + fi + + - name: Create Pull Request + env: + GH_TOKEN: ${{ github.token }} + run: | + BRANCH_NAME="release/v${{ steps.bump.outputs.version }}" + + gh pr create \ + --base main \ + --head $BRANCH_NAME \ + --title "Release v${{ steps.bump.outputs.version }}" \ + --body "## 🚀 Release v${{ steps.bump.outputs.version }} + + This PR was automatically created by the release workflow. + + ### ⚠️ Pre-merge Checklist + - [ ] Review CHANGELOG.md - ensure it has meaningful release notes + - [ ] Verify version numbers are correct in all files + - [ ] All CI checks are passing + + ### 📝 How to improve changelog + If the auto-generated changelog isn't good enough: + 1. Edit CHANGELOG.md in this PR + 2. Commit the changes + 3. Then approve and merge + + ### 🔄 Release Process + After merging this PR: + 1. Package will be built and tested + 2. Published to Test PyPI automatically + 3. **Manual approval required** before production PyPI + 4. GitHub release and tag created after production + + ### 🚨 Running from: ${{ github.ref }} + ${{ github.ref != 'refs/heads/main' && '**WARNING**: Not running from main branch!' || '✅ Running from main branch' }} + + --- + *Triggered by @${{ github.actor }}*" + + test-and-build: + name: Test and Build + needs: prepare-release + runs-on: ubuntu-latest + steps: - uses: actions/checkout@v4 + with: + ref: release/v${{ needs.prepare-release.outputs.version }} + + - name: Verify version before build + run: | + EXPECTED_VERSION="${{ needs.prepare-release.outputs.version }}" + ACTUAL_VERSION=$(grep -m1 -oP '^version = "\K[^"]+' pyproject.toml) + + echo "Expected version: $EXPECTED_VERSION" + echo "Actual version: $ACTUAL_VERSION" + + if [ "$ACTUAL_VERSION" != "$EXPECTED_VERSION" ]; then + echo "❌ ERROR: Version mismatch!" + exit 1 + fi + + echo "✓ Version verified: $ACTUAL_VERSION" - name: Set up Python uses: actions/setup-python@v5 @@ -36,17 +182,20 @@ jobs: curl -LsSf https://astral.sh/uv/install.sh | sh echo "$HOME/.local/bin" >> $GITHUB_PATH - # Add virtual environment creation - - name: Create virtual environment - run: uv venv - - - name: Build package - run: uv build + - name: Setup build environment + run: | + uv venv + source .venv/bin/activate + uv pip install build twine - - name: Check package + - name: Build and check package run: | - uv pip install twine - uv run twine check dist/* + source .venv/bin/activate + uv build + twine check dist/* + + echo "=== Package contents ===" + python -m zipfile -l dist/*.whl | head -20 - name: Upload artifacts uses: actions/upload-artifact@v4 @@ -54,13 +203,12 @@ jobs: name: dist path: dist/ - test-pypi: - name: Upload to TestPyPI - needs: build + publish-testpypi: + name: Publish to TestPyPI + needs: test-and-build runs-on: ubuntu-latest environment: name: test-pypi - url: https://test.pypi.org/project/bedrock-agentcore/ steps: - name: Download artifacts @@ -76,47 +224,92 @@ jobs: skip-existing: true password: ${{ secrets.TEST_PYPI_API_TOKEN }} - pypi: - name: Upload to PyPI - needs: test-pypi + release-approval: + name: Release Approval + needs: publish-testpypi runs-on: ubuntu-latest - if: github.event_name == 'push' || github.event.inputs.test_release == 'false' + # IMPORTANT: Always run if test PyPI succeeded + if: always() && needs.publish-testpypi.result == 'success' + environment: + name: pypi-approval + + steps: + - name: Approval checkpoint + run: | + echo "✅ TestPyPI deployment successful" + echo "📦 Package available at: https://test.pypi.org/project/bedrock-agentcore/" + echo "" + echo "⚠️ MANUAL APPROVAL REQUIRED FOR PRODUCTION" + echo "" + echo "Before approving:" + echo "1. Test package: pip install -i https://test.pypi.org/simple/ bedrock-agentcore" + echo "2. Verify functionality works" + echo "3. Check version is correct" + echo "" + echo "🚨 Only approve if everything works correctly!" + + publish-pypi: + name: Publish to PyPI + needs: release-approval + runs-on: ubuntu-latest + # CRITICAL: Only run from main branch + if: github.ref == 'refs/heads/main' environment: name: pypi url: https://pypi.org/project/bedrock-agentcore/ steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Download artifacts uses: actions/download-artifact@v4 with: name: dist path: dist/ + - name: Verify PyPI token exists + run: | + if [ -z "${{ secrets.PYPI_API_TOKEN }}" ]; then + echo "❌ ERROR: PYPI_API_TOKEN not configured!" + echo "Please add your PyPI API token to GitHub Secrets" + exit 1 + fi + echo "✓ PyPI token is configured" + + - name: Get version + id: version + run: | + VERSION=$(ls dist/*.whl | sed -n 's/.*-\([0-9.]*\)-.*/\1/p') + echo "version=$VERSION" >> $GITHUB_OUTPUT + - name: Publish to PyPI uses: pypa/gh-action-pypi-publish@release/v1 with: + # MUST specify password to avoid Trusted Publishing password: ${{ secrets.PYPI_API_TOKEN }} + skip-existing: false - github-release: - name: Create GitHub Release - needs: pypi - runs-on: ubuntu-latest - permissions: - contents: write - - steps: - - uses: actions/checkout@v4 - - - name: Download artifacts - uses: actions/download-artifact@v4 - with: - name: dist - path: dist/ + - name: Create and push tag + run: | + git config --global user.name "github-actions[bot]" + git config --global user.email "github-actions[bot]@users.noreply.github.com" + git tag -a v${{ steps.version.outputs.version }} -m "Release v${{ steps.version.outputs.version }}" + git push origin v${{ steps.version.outputs.version }} - - name: Create Release + - name: Create GitHub Release uses: softprops/action-gh-release@v2 with: + tag_name: v${{ steps.version.outputs.version }} + name: Bedrock AgentCore SDK v${{ steps.version.outputs.version }} files: dist/* generate_release_notes: true - draft: false - prerelease: false + body: | + ## Installation + ```bash + pip install bedrock-agentcore==${{ steps.version.outputs.version }} + ``` + + ## What's Changed + See [CHANGELOG.md](https://github.com/${{ github.repository }}/blob/v${{ steps.version.outputs.version }}/CHANGELOG.md) for details. diff --git a/scripts/bump_version.py b/scripts/bump_version.py new file mode 100755 index 0000000..da834bf --- /dev/null +++ b/scripts/bump_version.py @@ -0,0 +1,259 @@ +import re +import subprocess +import sys +from datetime import datetime +from pathlib import Path +from typing import Optional, Tuple + + +def get_current_version() -> str: + """Get current version from pyproject.toml.""" + content = Path("pyproject.toml").read_text() + # More robust regex that matches the project version specifically + # Look for version under [project] or [tool.poetry] sections + pattern = r'(?:^\[project\]|\[tool\.poetry\])[\s\S]*?^version\s*=\s*"([^"]+)"' + match = re.search(pattern, content, re.MULTILINE) + if not match: + raise ValueError("Version not found in pyproject.toml under [project] or [tool.poetry]") + return match.group(1) + + +def parse_version(version: str) -> Tuple[int, int, int, Optional[str]]: + """Parse semantic version string.""" + # Handle versions with pre-release tags + match = re.match(r"(\d+)\.(\d+)\.(\d+)(?:-(.+))?", version) + if not match: + raise ValueError(f"Invalid version format: {version}") + + major, minor, patch = int(match.group(1)), int(match.group(2)), int(match.group(3)) + pre_release = match.group(4) + return major, minor, patch, pre_release + + +def bump_version(current: str, bump_type: str) -> str: + """Bump version based on type.""" + major, minor, patch, pre_release = parse_version(current) + + if bump_type == "major": + return f"{major + 1}.0.0" + elif bump_type == "minor": + return f"{major}.{minor + 1}.0" + elif bump_type == "patch": + return f"{major}.{minor}.{patch + 1}" + elif bump_type == "pre": + if pre_release: + # Increment pre-release number + match = re.match(r"(.+?)(\d+)$", pre_release) + if match: + prefix, num = match.groups() + return f"{major}.{minor}.{patch}-{prefix}{int(num) + 1}" + return f"{major}.{minor}.{patch + 1}-rc1" + else: + raise ValueError(f"Unknown bump type: {bump_type}") + + +def update_version_in_file(file_path: Path, old_version: str, new_version: str) -> bool: + """Update version in a file. + + Note: Currently only bedrock_agentcore/__init__.py contains version. + This function is kept for potential future use. + """ + if not file_path.exists(): + return False + + content = file_path.read_text() + + # Only update __version__ assignments, not imports or other references + pattern = rf'^(__version__\s*=\s*["\'])({re.escape(old_version)})(["\'])' + new_content = re.sub(pattern, rf"\1{new_version}\3", content, flags=re.MULTILINE) + + if new_content != content: + file_path.write_text(new_content) + return True + return False + + +def update_all_versions(old_version: str, new_version: str): + """Update version in all relevant files.""" + # Update pyproject.toml - be specific about which version field + pyproject = Path("pyproject.toml") + content = pyproject.read_text() + + # Only update the version in [project] or [tool.poetry] section + # This prevents accidentally updating version constraints in dependencies + lines = content.split("\n") + in_project_section = False + updated_lines = [] + version_updated = False + + for line in lines: + if line.strip() == "[project]" or line.strip() == "[tool.poetry]": + in_project_section = True + elif line.strip().startswith("[") and line.strip() != "[project]": + in_project_section = False + + if in_project_section and line.strip().startswith("version = "): + updated_lines.append(f'version = "{new_version}"') + version_updated = True + else: + updated_lines.append(line) + + if not version_updated: + raise ValueError("Failed to update version in pyproject.toml") + + pyproject.write_text("\n".join(updated_lines)) + print("✓ Updated pyproject.toml") + + # Update __init__.py files that contain version + # Currently only bedrock_agentcore/__init__.py has __version__ + init_file = Path("src/bedrock_agentcore/__init__.py") + if init_file.exists() and update_version_in_file(init_file, old_version, new_version): + print(f"✓ Updated {init_file}") + + +def format_git_log(git_log: str) -> str: + """Format git log entries for changelog. + + Groups commits by type and formats them nicely. + """ + if not git_log.strip(): + return "" + + # Parse commit messages + fixes = [] + features = [] + docs = [] + other = [] + + for line in git_log.strip().split("\n"): + line = line.strip() + if not line or not line.startswith("-"): + continue + + # Remove the leading "- " and extract commit message + commit_msg = line[2:].strip() + + # Categorize by conventional commit type + if commit_msg.startswith("fix:") or commit_msg.startswith("bugfix:"): + fixes.append(commit_msg) + elif commit_msg.startswith("feat:") or commit_msg.startswith("feature:"): + features.append(commit_msg) + elif commit_msg.startswith("docs:") or commit_msg.startswith("doc:"): + docs.append(commit_msg) + else: + other.append(commit_msg) + + # Build formatted output + sections = [] + + if features: + sections.append("### Added\n" + "\n".join(f"- {msg}" for msg in features)) + + if fixes: + sections.append("### Fixed\n" + "\n".join(f"- {msg}" for msg in fixes)) + + if docs: + sections.append("### Documentation\n" + "\n".join(f"- {msg}" for msg in docs)) + + if other: + sections.append("### Other Changes\n" + "\n".join(f"- {msg}" for msg in other)) + + return "\n\n".join(sections) + + +def get_git_log(since_tag: Optional[str] = None) -> str: + """Get git commit messages since last tag.""" + cmd = ["git", "log", "--pretty=format:- %s (%h)"] + if since_tag: + cmd.append(f"{since_tag}..HEAD") + else: + # Get commits since last tag + try: + last_tag = subprocess.run( + ["git", "describe", "--tags", "--abbrev=0"], capture_output=True, text=True, check=True + ).stdout.strip() + cmd.append(f"{last_tag}..HEAD") + except subprocess.CalledProcessError: + # No tags, get last 20 commits + cmd.extend(["-n", "20"]) + + result = subprocess.run(cmd, capture_output=True, text=True) + return result.stdout + + +def update_changelog(new_version: str, changes: str = None): + """Update CHANGELOG.md with new version.""" + changelog_path = Path("CHANGELOG.md") + + if not changelog_path.exists(): + content = "# Changelog\n\nAll notable changes to this project will be documented in this file.\n\n" + else: + content = changelog_path.read_text() + + date = datetime.now().strftime("%Y-%m-%d") + entry = f"\n## [{new_version}] - {date}\n\n" + + if changes: + # Use provided changelog + entry += "### Changes\n\n" + entry += changes + "\n" + else: + # Warn about auto-generation + print("\n⚠️ No changelog provided. Auto-generating from commits.") + print("💡 Tip: Use --changelog to provide meaningful release notes") + print(' Example: --changelog "Added new CLI commands for gateway management"') + + git_log = get_git_log() + if git_log: + entry += "### Changes (auto-generated from commits)\n\n" + entry += git_log + "\n" + entry += "\n*Note: Consider providing a custom changelog for better release notes*\n" + + # Insert after header + if "# Changelog" in content: + parts = content.split("\n", 2) + content = parts[0] + "\n" + entry + "\n" + (parts[2] if len(parts) > 2 else "") + else: + content = "# Changelog\n" + entry + "\n" + content + + changelog_path.write_text(content) + print("✓ Updated CHANGELOG.md") + + +def main(): + import argparse + + parser = argparse.ArgumentParser(description="Bump SDK version") + parser.add_argument("bump_type", choices=["major", "minor", "patch", "pre"], help="Type of version bump") + parser.add_argument("--changelog", help="Custom changelog entry") + parser.add_argument("--dry-run", action="store_true", help="Show what would be done") + + args = parser.parse_args() + + try: + current = get_current_version() + new = bump_version(current, args.bump_type) + + print(f"Current version: {current}") + print(f"New version: {new}") + + if args.dry_run: + print("\nDry run - no changes made") + return + + update_all_versions(current, new) + update_changelog(new, args.changelog) + + print(f"\n✓ Version bumped from {current} to {new}") + print("\nNext steps:") + print("1. Review changes: git diff") + print("2. Commit: git add -A && git commit -m 'chore: bump version to {}'".format(new)) + print("3. Create PR or push to trigger release workflow") + + except Exception as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + main()