diff --git a/.github/workflows/bump-package.yml b/.github/workflows/bump-package.yml index ac18c38..d3c0d29 100644 --- a/.github/workflows/bump-package.yml +++ b/.github/workflows/bump-package.yml @@ -17,6 +17,7 @@ jobs: runs-on: ubuntu-latest permissions: contents: write + pull-requests: write environment: pypi-release steps: - name: Setup Python @@ -37,5 +38,7 @@ jobs: run: uv tool install commitizen - name: Bump version for ${{ inputs.package_name }} + env: + GH_TOKEN: ${{ secrets.RELEASE_GITHUB_PAT }} run: | uv run python scripts/bump_package.py "${{ inputs.package_name }}" "${{ inputs.package_dir }}" diff --git a/scripts/bump_package.py b/scripts/bump_package.py index 927e76b..0ea49f3 100644 --- a/scripts/bump_package.py +++ b/scripts/bump_package.py @@ -1,27 +1,55 @@ #!/usr/bin/env python3 -""" -Bump package version script. - -This script handles version bumping for a specific package using commitizen, -including retry logic for pushing changes to avoid race conditions. +"""Bump package version via an auto-merging PR. + +Compatible with branch-protection rulesets that require all changes to land +through PRs and require commits to be signed: + +1. ``cz bump --files-only`` updates ``pyproject.toml`` (cz version field) and + ``CHANGELOG.md`` in the package directory; no local commit or tag. +2. A new branch ``bump/-`` is created on the remote at the + current main tip via the REST refs API. +3. The bumped files are committed onto that branch via the GraphQL + ``createCommitOnBranch`` mutation, which signs the commit as the + authenticated bot identity. +4. A PR is opened with ``--squash --auto`` so it merges itself once + required CI checks pass on it. +5. The script polls until the PR merges, captures the squash-merge SHA on + ``main``, then creates and pushes the ``-`` tag at + that SHA. Tags trigger the existing ``release.yml`` publish workflow. + +The runner needs: + +- ``GH_TOKEN`` (or ``GITHUB_TOKEN``) in env, scoped to allow ``gh api`` + calls and PR creation. Provided by the workflow via + ``secrets.RELEASE_GITHUB_PAT``. +- A repo configured with auto-merge enabled and a squash-merge option. """ import argparse +import base64 +import json +import os +import re import subprocess import sys +import tempfile import time from pathlib import Path -def run_command(cmd: list[str], cwd: str | None = None) -> tuple[int, str, str]: +def run_command(cmd: list[str], cwd: str | None = None, env: dict | None = None) -> tuple[int, str, str]: """Run a command and return exit code, stdout, and stderr.""" try: + merged_env = os.environ.copy() + if env: + merged_env.update(env) result = subprocess.run( cmd, capture_output=True, text=True, cwd=cwd, - check=False + check=False, + env=merged_env, ) return result.returncode, result.stdout.strip(), result.stderr.strip() except Exception as e: @@ -29,224 +57,385 @@ def run_command(cmd: list[str], cwd: str | None = None) -> tuple[int, str, str]: def configure_git() -> None: - """Configure git for automated commits.""" print("Configuring git...") run_command(["git", "config", "--local", "user.email", "action@github.com"]) run_command(["git", "config", "--local", "user.name", "GitHub Action"]) -def pull_latest_changes() -> bool: - """Pull latest changes from origin/main, handling local modifications.""" - print("Checking for local changes before pulling...") +def get_repo_slug() -> str: + """Return ``owner/repo`` for the current checkout, e.g. ``keycardai/python-sdk``.""" + exit_code, stdout, _ = run_command( + ["gh", "repo", "view", "--json", "nameWithOwner", "-q", ".nameWithOwner"] + ) + if exit_code != 0 or not stdout: + print("Failed to determine repository slug from gh CLI") + sys.exit(1) + return stdout - # Check if there are local changes - exit_code, stdout, stderr = run_command(["git", "status", "--porcelain"]) +def get_main_sha() -> str: + """Return the current commit SHA on origin/main.""" + exit_code, stdout, stderr = run_command(["git", "rev-parse", "origin/main"]) if exit_code != 0: - print(f"Failed to check git status: {stderr}") - return False - - has_local_changes = bool(stdout.strip()) - - if has_local_changes: - print(f"Found local changes:\n{stdout}") - print("Stashing local changes before pulling...") - - # Stash local changes - exit_code, stdout, stderr = run_command(["git", "stash", "push", "-m", "Auto-stash before version bump"]) - - if exit_code != 0: - print(f"Failed to stash local changes: {stderr}") - return False + print(f"Failed to read origin/main: {stderr}") + sys.exit(1) + return stdout - print("Successfully stashed local changes") +def pull_main() -> bool: print("Pulling latest changes from origin/main...") - exit_code, stdout, stderr = run_command(["git", "pull", "origin", "main"]) - + exit_code, _, stderr = run_command(["git", "fetch", "origin", "main"]) if exit_code != 0: - print(f"Failed to pull latest changes: {stderr}") - - # If we stashed changes, try to restore them - if has_local_changes: - print("Attempting to restore stashed changes...") - run_command(["git", "stash", "pop"]) - + print(f"Failed to fetch origin/main: {stderr}") + return False + exit_code, _, stderr = run_command(["git", "reset", "--hard", "origin/main"]) + if exit_code != 0: + print(f"Failed to reset to origin/main: {stderr}") return False + return True - print("Successfully pulled latest changes") - # If we stashed changes, restore them - if has_local_changes: - print("Restoring stashed changes...") - exit_code, stdout, stderr = run_command(["git", "stash", "pop"]) +def cz_bump_files_only(package_dir: str, package_name: str) -> str | None: + """Run ``cz bump --files-only`` and return the new version string. - if exit_code != 0: - print(f"Warning: Failed to restore stashed changes: {stderr}") + cz prints a line like ``bump: keycardai-a2a 0.2.0 -> 0.3.0`` to stdout; + we parse that for the new version. Returns ``None`` if cz failed or + the version transition could not be determined (e.g. nothing to bump). + """ + print(f"Running cz bump --files-only for {package_name}...") + exit_code, stdout, stderr = run_command( + ["uv", "run", "cz", "bump", "--changelog", "--yes", "--files-only"], + cwd=package_dir, + ) - # If the conflict is in uv.lock, we can try to resolve it automatically - if "uv.lock" in stderr: - print("Detected uv.lock conflict. Attempting automatic resolution...") + if exit_code != 0: + if "NO_COMMITS_TO_BUMP" in stderr or "no eligible commits" in stderr.lower(): + print("cz reports no eligible commits since last tag; nothing to bump.") + return None + print(f"cz bump failed (exit {exit_code}): {stderr}") + sys.exit(1) - # Re-sync dependencies to regenerate uv.lock - sync_exit_code, sync_stdout, sync_stderr = run_command(["uv", "sync", "--all-extras", "--all-packages"]) + print(stdout) - if sync_exit_code == 0: - print("Successfully regenerated uv.lock after conflict") - # Drop the stash since we've resolved it - run_command(["git", "stash", "drop"]) - else: - print(f"Failed to regenerate uv.lock: {sync_stderr}") - print("Manual intervention may be required") - else: - print("Non-uv.lock conflict detected. Manual intervention may be required") + match = re.search(r"\b(\d+\.\d+\.\d+)\s*(?:→|->|to)\s*(\d+\.\d+\.\d+)", stdout) + if not match: + print(f"Could not parse new version from cz output: {stdout}") + sys.exit(1) + return match.group(2) - # Don't fail the process - the pull succeeded +def get_modified_files() -> list[str]: + """Return the list of files changed in the working tree (relative paths).""" + exit_code, stdout, stderr = run_command(["git", "diff", "--name-only"]) + if exit_code != 0: + print(f"Failed to list modified files: {stderr}") + sys.exit(1) + return [line for line in stdout.splitlines() if line] + + +def create_remote_branch(repo: str, branch: str, sha: str) -> bool: + print(f"Creating remote branch {branch} at {sha[:8]}...") + exit_code, _, stderr = run_command( + [ + "gh", + "api", + f"repos/{repo}/git/refs", + "-X", + "POST", + "-f", + f"ref=refs/heads/{branch}", + "-f", + f"sha={sha}", + ] + ) + if exit_code != 0: + print(f"Failed to create remote branch: {stderr}") + return False return True -def run_bump(package_dir: str, package_name: str) -> bool: - """Run commitizen bump in the specified package directory.""" - print(f"Running version bump for {package_name} in {package_dir}...") +def create_signed_commit_on_branch( + repo: str, + branch: str, + parent_sha: str, + files: list[str], + headline: str, + body: str, +) -> bool: + """Submit a signed commit to ``branch`` via the GraphQL + ``createCommitOnBranch`` mutation. + + Each file's current working-tree content is base64-encoded and sent as + a file addition. The commit is signed by GitHub as the authenticated + user (the bot identity owning ``GH_TOKEN``). + """ + print(f"Creating signed commit on {branch} via GraphQL mutation...") + + additions = [] + for path in files: + content = Path(path).read_bytes() + additions.append( + { + "path": path, + "contents": base64.b64encode(content).decode("ascii"), + } + ) - exit_code, stdout, stderr = run_command( - ["uv", "run", "cz", "bump", "--changelog", "--yes"], - cwd=package_dir + mutation = ( + "mutation($input: CreateCommitOnBranchInput!) {" + " createCommitOnBranch(input: $input) {" + " commit { oid url }" + " }" + "}" ) + request_body = { + "query": mutation, + "variables": { + "input": { + "branch": {"repositoryNameWithOwner": repo, "branchName": branch}, + "expectedHeadOid": parent_sha, + "fileChanges": {"additions": additions}, + "message": {"headline": headline, "body": body}, + } + }, + } + + # gh api graphql --input is the path that accepts a fully-formed + # request body. --raw-field variables=... does not preserve nested JSON. + with tempfile.NamedTemporaryFile( + "w", suffix=".json", delete=False + ) as tmp: + json.dump(request_body, tmp) + tmp_path = tmp.name + + try: + exit_code, stdout, stderr = run_command( + ["gh", "api", "graphql", "--input", tmp_path] + ) + finally: + os.unlink(tmp_path) if exit_code != 0: - print(f"Failed to bump version: {stderr}") + print(f"GraphQL createCommitOnBranch failed: {stderr}") return False - print("Version bump completed successfully") - print(stdout) + try: + payload = json.loads(stdout) + if "errors" in payload: + print(f"GraphQL returned errors: {payload['errors']}") + return False + oid = payload["data"]["createCommitOnBranch"]["commit"]["oid"] + print(f"Created signed commit {oid[:8]} on {branch}") + except (json.JSONDecodeError, KeyError) as e: + print(f"Unexpected GraphQL response shape: {stdout} ({e})") + return False return True -def push_changes_with_retry(max_attempts: int = 3) -> bool: - """Push changes to origin/main with retry logic.""" - # First, let's check what tags were created - exit_code, stdout, stderr = run_command(["git", "tag", "--list", "--sort=-version:refname"]) - if exit_code == 0 and stdout: - print(f"Local tags found: {stdout}") - # Show the most recent tag - recent_tags = stdout.split('\n')[:3] - print(f"Most recent tags: {recent_tags}") - - for attempt in range(1, max_attempts + 1): - print(f"Attempting to push changes (attempt {attempt}/{max_attempts})...") +def create_pr_with_automerge( + branch: str, package_name: str, new_version: str +) -> int | None: + """Open a PR for the bump branch with auto-merge (squash) enabled. - exit_code, stdout, stderr = run_command( - ["git", "push", "origin", "main", "--follow-tags"] - ) + Returns the PR number on success, ``None`` on failure. + """ + title = f"bump: {package_name} → {new_version}" + pr_body = ( + f"Auto-bump for `{package_name}` to `{new_version}`.\n\n" + "Generated by `scripts/bump_package.py`. The branch tag will be " + "created at the squash-merge SHA after this PR merges, which " + "triggers the publish workflow." + ) - if exit_code == 0: - print(f"Successfully pushed changes on attempt {attempt}") + print(f"Opening PR for {branch}...") + exit_code, stdout, stderr = run_command( + [ + "gh", + "pr", + "create", + "--head", + branch, + "--base", + "main", + "--title", + title, + "--body", + pr_body, + ] + ) + if exit_code != 0: + print(f"Failed to create PR: {stderr}") + return None + + pr_url = stdout.strip().splitlines()[-1] + pr_number_match = re.search(r"/pull/(\d+)", pr_url) + if not pr_number_match: + print(f"Could not parse PR number from gh output: {pr_url}") + return None + pr_number = int(pr_number_match.group(1)) + print(f"Opened PR #{pr_number}: {pr_url}") + + print("Enabling auto-merge (squash)...") + exit_code, _, stderr = run_command( + ["gh", "pr", "merge", str(pr_number), "--auto", "--squash"] + ) + if exit_code != 0: + print(f"Failed to enable auto-merge: {stderr}") + return None + return pr_number - # Explicitly push tags to ensure they're uploaded - print("Explicitly pushing tags...") - tag_exit_code, tag_stdout, tag_stderr = run_command( - ["git", "push", "origin", "--tags"] - ) - if tag_exit_code == 0: - print("Successfully pushed tags") - else: - print(f"Warning: Failed to push tags: {tag_stderr}") - # Don't fail the whole process for tag push failure +def wait_for_pr_merge(pr_number: int, timeout_seconds: int = 1800) -> str | None: + """Poll the PR until it merges. Returns the merge commit SHA on main. - return True + Fails if the PR is closed without merging or if the timeout elapses. + Polls every 30s; logs each status change so the run is debuggable. + """ + print(f"Waiting for PR #{pr_number} to merge (timeout {timeout_seconds}s)...") + deadline = time.time() + timeout_seconds + last_state = None - print(f"Push failed on attempt {attempt}: {stderr}") + while time.time() < deadline: + exit_code, stdout, stderr = run_command( + [ + "gh", + "pr", + "view", + str(pr_number), + "--json", + "state,mergeCommit,statusCheckRollup", + ] + ) + if exit_code != 0: + print(f"Failed to read PR status: {stderr}") + time.sleep(30) + continue + + try: + data = json.loads(stdout) + except json.JSONDecodeError: + print(f"Could not parse PR status JSON: {stdout}") + time.sleep(30) + continue + + state = data.get("state") + if state != last_state: + print(f"PR #{pr_number} state: {state}") + last_state = state + + if state == "MERGED": + merge_commit = data.get("mergeCommit") or {} + sha = merge_commit.get("oid") + if not sha: + print("PR is MERGED but no mergeCommit oid was returned.") + return None + print(f"PR #{pr_number} merged at {sha[:8]}") + return sha + + if state == "CLOSED": + print(f"PR #{pr_number} was closed without merging.") + return None + + time.sleep(30) + + print(f"Timeout waiting for PR #{pr_number} to merge.") + return None + + +def create_and_push_tag(repo: str, tag: str, sha: str) -> bool: + """Create the tag on the remote pointing at ``sha`` and push it. + + Uses the REST refs API rather than ``git push --tags`` so the operation + works even if the runner's local main is behind (the workflow doesn't + re-fetch after the merge poll). + """ + print(f"Creating tag {tag} at {sha[:8]} via REST refs API...") + exit_code, _, stderr = run_command( + [ + "gh", + "api", + f"repos/{repo}/git/refs", + "-X", + "POST", + "-f", + f"ref=refs/tags/{tag}", + "-f", + f"sha={sha}", + ] + ) + if exit_code != 0: + print(f"Failed to create tag: {stderr}") + return False + print(f"Created tag {tag}") + return True - if attempt < max_attempts: - print("Pulling latest changes and retrying...") - # Use the improved pull function that handles local changes - if not pull_latest_changes(): - print("Failed to pull latest changes during retry") - continue +def bump_package(package_name: str, package_dir: str) -> bool: + print(f"Starting version bump for {package_name}...") - print("Waiting 2 seconds before retry...") - time.sleep(2) - else: - print(f"Failed to push after {max_attempts} attempts") - return False + if not Path(package_dir).exists(): + print(f"Error: package directory {package_dir} does not exist") + return False - return False + configure_git() + if not pull_main(): + return False -def bump_package(package_name: str, package_dir: str) -> bool: - """ - Bump version for a specific package. + new_version = cz_bump_files_only(package_dir, package_name) + if new_version is None: + return True - Args: - package_name: Name of the package (e.g., keycardai-oauth) - package_dir: Directory path of the package (e.g., packages/oauth) + repo = get_repo_slug() + branch = f"bump/{package_name}-{new_version}" + tag = f"{new_version}-{package_name}" + parent_sha = get_main_sha() - Returns: - True if successful, False otherwise - """ - print(f"Starting version bump for {package_name} package...") + modified = get_modified_files() + if not modified: + print("cz bump produced no file changes; nothing to commit.") + return False + print(f"Modified files: {modified}") - # Ensure package directory exists - if not Path(package_dir).exists(): - print(f"Error: Package directory {package_dir} does not exist") + if not create_remote_branch(repo, branch, parent_sha): return False - # Configure git for automated commits - configure_git() + if not create_signed_commit_on_branch( + repo, + branch, + parent_sha, + modified, + headline=f"bump: {package_name} → {new_version}", + body=f"Auto-bump for {package_name}.", + ): + return False - # Pull latest changes to avoid conflicts - if not pull_latest_changes(): + pr_number = create_pr_with_automerge(branch, package_name, new_version) + if pr_number is None: return False - # Run the version bump - if not run_bump(package_dir, package_name): + merge_sha = wait_for_pr_merge(pr_number) + if merge_sha is None: return False - # Push changes with retry logic - if not push_changes_with_retry(): + if not create_and_push_tag(repo, tag, merge_sha): return False - print(f"Successfully completed version bump for {package_name}") + print(f"Successfully bumped {package_name} to {new_version}; tag {tag} pushed.") return True -def main(): - """Main function with argument parsing.""" +def main() -> None: parser = argparse.ArgumentParser( - description="Bump package version using commitizen" - ) - parser.add_argument( - "package_name", - help="Name of the package to bump (e.g., keycardai-oauth)" - ) - parser.add_argument( - "package_dir", - help="Directory of the package (e.g., packages/oauth)" + description="Bump a package version via an auto-merging PR." ) - parser.add_argument( - "--max-retry-attempts", - type=int, - default=3, - help="Maximum number of push retry attempts (default: 3)" - ) - + parser.add_argument("package_name", help="Package name (e.g. keycardai-oauth).") + parser.add_argument("package_dir", help="Package directory (e.g. packages/oauth).") args = parser.parse_args() - # Set max retry attempts globally - global MAX_RETRY_ATTEMPTS - MAX_RETRY_ATTEMPTS = args.max_retry_attempts - - # Run the bump - success = bump_package(args.package_name, args.package_dir) - - if not success: + if not bump_package(args.package_name, args.package_dir): print("Version bump failed") sys.exit(1) - print("Version bump completed successfully")