diff --git a/.github/workflows/bump-package.yml b/.github/workflows/bump-package.yml index 0392ecc..114631f 100644 --- a/.github/workflows/bump-package.yml +++ b/.github/workflows/bump-package.yml @@ -11,6 +11,37 @@ on: required: true type: string description: "Directory of the package (e.g., packages/oauth)" + target_branch: + required: false + type: string + default: main + description: "Branch that receives the bump PR" + increment: + required: false + type: string + default: auto + description: "Optional forced increment: major, minor, or patch" + workflow_dispatch: + inputs: + package_name: + required: true + type: string + package_dir: + required: true + type: string + target_branch: + required: false + type: string + default: main + increment: + required: false + type: choice + default: auto + options: + - auto + - major + - minor + - patch jobs: bump: @@ -48,4 +79,8 @@ jobs: env: GH_TOKEN: ${{ steps.generate-token.outputs.token }} run: | - uv run python scripts/bump_package.py "${{ inputs.package_name }}" "${{ inputs.package_dir }}" + ARGS=(--target-branch "${{ inputs.target_branch }}") + if [[ "${{ inputs.increment }}" != "auto" && -n "${{ inputs.increment }}" ]]; then + ARGS+=(--increment "${{ inputs.increment }}") + fi + uv run python scripts/bump_package.py "${{ inputs.package_name }}" "${{ inputs.package_dir }}" "${ARGS[@]}" diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 1f6a56d..1576b6b 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -4,9 +4,10 @@ on: push: branches: - main + - release/mcp-v1 concurrency: - group: main + group: release-${{ github.ref_name }} cancel-in-progress: false jobs: @@ -42,6 +43,9 @@ jobs: id: changes run: | PACKAGES=$(just detect-changes) + if [[ "${{ github.ref_name }}" == "release/mcp-v1" ]]; then + PACKAGES=$(jq -c '[.[] | select(.package_name == "keycardai-mcp")]' <<< "$PACKAGES") + fi echo "changed-packages=$PACKAGES" >> $GITHUB_OUTPUT bump-packages: @@ -58,4 +62,5 @@ jobs: with: package_name: ${{ matrix.package.package_name }} package_dir: ${{ matrix.package.package_dir }} - secrets: inherit \ No newline at end of file + target_branch: ${{ github.ref_name }} + secrets: inherit diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 2a883d5..538996a 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -4,6 +4,7 @@ on: pull_request: branches: - main + - release/mcp-v1 permissions: contents: read diff --git a/MCP_RELEASES.md b/MCP_RELEASES.md new file mode 100644 index 0000000..c0f877f --- /dev/null +++ b/MCP_RELEASES.md @@ -0,0 +1,53 @@ +# MCP release lines + +`keycardai-mcp` tracks the upstream `mcp` package's major version, so its major +version tells you which protocol SDK generation it pairs with: + +| keycardai-mcp | pairs with | released from | +|---|---|---| +| `1.x` | `mcp >= 1.28, < 2.0` | `release/mcp-v1` | +| `2.x` | `mcp >= 2.0, < 3.0` | `main` | + +PyPI has no dist-tags, so nothing here changes how versions are published; the +dependency ranges above are what steer resolvers to the right line. Users stay +on 1.x with `keycardai-mcp<2`, which is also what dependents that still pin +`mcp<2.0` (fastmcp 3.x, and the agent frameworks) resolve to naturally. + +## Branches + +- `main` is the 2.x line. Normal development happens here. +- `release/mcp-v1` publishes fixes for the 1.x line. It receives + security/critical fixes only, no features. To land a fix there, + cherry-pick it into a PR targeting `release/mcp-v1`; the merge-to-branch + workflow auto-bumps and publishes exactly as `main` does. Only + `keycardai-mcp` bumps from this branch — the other packages release from + `main` regardless of line. (This differs from typescript-sdk, where the + aggregate `@keycardai/sdk` also releases from the maintenance branch; the + Python root `keycardai` package does not depend on `keycardai-mcp`.) + +## Forced increments + +Use the **Bump Package Version** workflow (workflow_dispatch) when a forced +increment is required — deliberate majors, or establishing a new release line. +`auto` derives the increment from conventional commits as usual. + +| input | maintenance release | major cut | +|---|---|---| +| `package_name` | `keycardai-mcp` | `keycardai-mcp` | +| `package_dir` | `packages/mcp` | `packages/mcp` | +| `target_branch` | `release/mcp-v1` | `main` | +| `increment` | `auto` | `major` | + +## Establishing the lines (one-time sequence) + +1. Release `keycardai-mcp@1.0.0` from `main` (forced `major`) while `main` + still carries the `mcp<2.0` constraint. +2. Create `release/mcp-v1` at the 1.0.0 release commit. +3. Merge the MCP 2.0 cutover into `main`; its `feat!` commit makes cz cut + `2.0.0` automatically. +4. Update `packages/fastmcp`'s `keycardai-mcp` constraint to `>=1,<2` in the + same window (it pairs with fastmcp 3.x / `mcp<2.0` until the fastmcp 4.x + bump). + +Do not create the maintenance branch before 1.0.0 exists; the branch must +contain the 1.0.0 tag's commit so cz derives 1.x patches from it. diff --git a/scripts/bump_package.py b/scripts/bump_package.py index 93d35d6..010c919 100644 --- a/scripts/bump_package.py +++ b/scripts/bump_package.py @@ -10,8 +10,8 @@ would double-bump. 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. +2. A release-line-specific bump branch is created on the remote at the + current target-branch 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. @@ -23,8 +23,8 @@ script only waited for auto-merge, i.e. 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 target branch, then creates and pushes the ``-`` tag + at that SHA. Tags trigger the existing ``release.yml`` publish workflow. The runner needs: @@ -82,24 +82,26 @@ def get_repo_slug() -> str: return stdout -def get_main_sha() -> str: - """Return the current commit SHA on origin/main.""" - exit_code, stdout, stderr = run_command(["git", "rev-parse", "origin/main"]) +def get_branch_sha(branch: str) -> str: + """Return the current commit SHA on an origin branch.""" + exit_code, stdout, stderr = run_command(["git", "rev-parse", f"origin/{branch}"]) if exit_code != 0: - print(f"Failed to read origin/main: {stderr}") + print(f"Failed to read origin/{branch}: {stderr}") sys.exit(1) return stdout -def pull_main() -> bool: - print("Pulling latest changes from origin/main...") - exit_code, _, stderr = run_command(["git", "fetch", "origin", "main"]) +def pull_branch(branch: str) -> bool: + print(f"Pulling latest changes from origin/{branch}...") + exit_code, _, stderr = run_command(["git", "fetch", "origin", branch]) if exit_code != 0: - print(f"Failed to fetch origin/main: {stderr}") + print(f"Failed to fetch origin/{branch}: {stderr}") return False - exit_code, _, stderr = run_command(["git", "reset", "--hard", "origin/main"]) + exit_code, _, stderr = run_command( + ["git", "reset", "--hard", f"origin/{branch}"] + ) if exit_code != 0: - print(f"Failed to reset to origin/main: {stderr}") + print(f"Failed to reset to origin/{branch}: {stderr}") return False return True @@ -127,8 +129,8 @@ def recover_untagged_bump(repo: str, package_name: str, package_dir: str) -> boo A bump PR can merge after this job's merge-wait times out, leaving the version files ahead of the last release tag with nothing to trigger the - publish. When the configured version has a merged bump commit on main - but no tag, push the tag at that commit and stop. + publish. When the configured version has a merged bump commit on the + target branch but no tag, push the tag at that commit and stop. Returns ``True`` when the missing tag was pushed (the bump is complete), ``False`` when the tag push failed, and ``None`` when there is nothing @@ -159,7 +161,9 @@ def recover_untagged_bump(repo: str, package_name: str, package_dir: str) -> boo return True -def cz_bump_files_only(package_dir: str, package_name: str) -> str | None: +def cz_bump_files_only( + package_dir: str, package_name: str, increment: str | None = None +) -> str | None: """Run ``cz bump --files-only`` and return the new version string. cz prints a line like ``bump: keycardai-a2a 0.2.0 -> 0.3.0`` to stdout; @@ -167,10 +171,10 @@ def cz_bump_files_only(package_dir: str, package_name: str) -> str | None: 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, - ) + command = ["uv", "run", "cz", "bump", "--changelog", "--yes", "--files-only"] + if increment: + command.extend(["--increment", increment.upper(), "--allow-no-commit"]) + exit_code, stdout, stderr = run_command(command, cwd=package_dir) if exit_code != 0: if "NO_COMMITS_TO_BUMP" in stderr or "no eligible commits" in stderr.lower(): @@ -197,6 +201,16 @@ def get_modified_files() -> list[str]: return [line for line in stdout.splitlines() if line] +def bump_branch_name(target_branch: str, package_name: str, version: str) -> str: + """Return a release-line-specific bump branch name. + + Namespacing by release line keeps a 1.x bump and a main bump of the same + package from colliding on one branch ref. + """ + release_line = re.sub(r"[^A-Za-z0-9._-]+", "-", target_branch) + return f"bump/{release_line}/{package_name}-{version}" + + 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( @@ -318,7 +332,7 @@ def wait_for_pr_stable(pr_number: int, timeout_seconds: int = 120) -> bool: def create_pr_with_automerge( - branch: str, package_name: str, new_version: str + branch: str, target_branch: str, package_name: str, new_version: str ) -> int | None: """Open a PR for the bump branch with auto-merge (squash) enabled. @@ -341,7 +355,7 @@ def create_pr_with_automerge( "--head", branch, "--base", - "main", + target_branch, "--title", title, "--body", @@ -391,8 +405,13 @@ def checks_green(pr_data: dict) -> bool: return True -def wait_for_pr_merge(repo: str, pr_number: int, timeout_seconds: int = 1800) -> str | None: - """Poll the PR until it merges. Returns the merge commit SHA on main. +def wait_for_pr_merge( + repo: str, + pr_number: int, + target_branch: str, + timeout_seconds: int = 1800, +) -> str | None: + """Poll the PR until it merges. Returns the merge commit SHA on the target branch. 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. @@ -447,7 +466,8 @@ def wait_for_pr_merge(repo: str, pr_number: int, timeout_seconds: int = 1800) -> # Auto-merge waits for requirements the app is entitled to bypass # (required reviews), and the merge API does not exercise ruleset # bypass either; ref updates do. Try the merge for the clean PR - # timeline, then fall back to fast-forwarding main to the PR head, + # timeline, then fall back to fast-forwarding the target branch to + # the PR head, # which GitHub records as merging the PR. direct_merge_attempts += 1 exit_code, _, stderr = run_command( @@ -465,20 +485,20 @@ def wait_for_pr_merge(repo: str, pr_number: int, timeout_seconds: int = 1800) -> "api", "-X", "PATCH", - f"repos/{repo}/git/refs/heads/main", + f"repos/{repo}/git/refs/heads/{target_branch}", "-f", f"sha={head_sha}", ] ) if exit_code == 0: print( - f"Fast-forwarded main to {head_sha[:8]}; " + f"Fast-forwarded {target_branch} to {head_sha[:8]}; " f"PR #{pr_number} will be marked merged." ) else: print( f"Fast-forward attempt {direct_merge_attempts} failed " - f"(main may have moved); auto-merge stays armed: " + f"({target_branch} may have moved); auto-merge stays armed: " f"{stderr.strip()[:200]}" ) @@ -516,8 +536,13 @@ def create_and_push_tag(repo: str, tag: str, sha: str) -> bool: return True -def bump_package(package_name: str, package_dir: str) -> bool: - print(f"Starting version bump for {package_name}...") +def bump_package( + package_name: str, + package_dir: str, + target_branch: str = "main", + increment: str | None = None, +) -> bool: + print(f"Starting version bump for {package_name} on {target_branch}...") if not Path(package_dir).exists(): print(f"Error: package directory {package_dir} does not exist") @@ -525,7 +550,7 @@ def bump_package(package_name: str, package_dir: str) -> bool: configure_git() - if not pull_main(): + if not pull_branch(target_branch): return False repo = get_repo_slug() @@ -534,12 +559,12 @@ def bump_package(package_name: str, package_dir: str) -> bool: if recovery is not None: return recovery - new_version = cz_bump_files_only(package_dir, package_name) + new_version = cz_bump_files_only(package_dir, package_name, increment) if new_version is None: return True - branch = f"bump/{package_name}-{new_version}" + branch = bump_branch_name(target_branch, package_name, new_version) tag = f"{new_version}-{package_name}" - parent_sha = get_main_sha() + parent_sha = get_branch_sha(target_branch) modified = get_modified_files() if not modified: @@ -560,11 +585,13 @@ def bump_package(package_name: str, package_dir: str) -> bool: ): return False - pr_number = create_pr_with_automerge(branch, package_name, new_version) + pr_number = create_pr_with_automerge( + branch, target_branch, package_name, new_version + ) if pr_number is None: return False - merge_sha = wait_for_pr_merge(repo, pr_number) + merge_sha = wait_for_pr_merge(repo, pr_number, target_branch) if merge_sha is None: return False @@ -581,9 +608,24 @@ def main() -> None: ) parser.add_argument("package_name", help="Package name (e.g. keycardai-oauth).") parser.add_argument("package_dir", help="Package directory (e.g. packages/oauth).") + parser.add_argument( + "--target-branch", + default="main", + help="Branch that receives the bump PR and the release tag.", + ) + parser.add_argument( + "--increment", + choices=["major", "minor", "patch"], + help="Force the version increment instead of deriving it from commits.", + ) args = parser.parse_args() - if not bump_package(args.package_name, args.package_dir): + if not bump_package( + args.package_name, + args.package_dir, + target_branch=args.target_branch, + increment=args.increment, + ): print("Version bump failed") sys.exit(1) print("Version bump completed successfully") diff --git a/scripts/test_bump_package.py b/scripts/test_bump_package.py new file mode 100644 index 0000000..7f8cc70 --- /dev/null +++ b/scripts/test_bump_package.py @@ -0,0 +1,98 @@ +"""Unit tests for the branch/increment plumbing in bump_package.py. + +Mirrors typescript-sdk's scripts/test_bump_package.py so the two release +pipelines stay symmetric. Run with: + + python3 -m unittest discover -s scripts -p 'test_*.py' +""" + +import unittest +from unittest import mock + +import bump_package + + +class BumpBranchNameTests(unittest.TestCase): + def test_main_line_branch_name(self) -> None: + self.assertEqual( + bump_package.bump_branch_name("main", "keycardai-mcp", "1.0.1"), + "bump/main/keycardai-mcp-1.0.1", + ) + + def test_release_line_slashes_are_sanitized(self) -> None: + self.assertEqual( + bump_package.bump_branch_name("release/mcp-v1", "keycardai-mcp", "1.0.1"), + "bump/release-mcp-v1/keycardai-mcp-1.0.1", + ) + + +class PullBranchTests(unittest.TestCase): + @mock.patch.object(bump_package, "run_command", return_value=(0, "", "")) + def test_pull_branch_fetches_and_resets_to_target(self, run_command) -> None: + self.assertTrue(bump_package.pull_branch("release/mcp-v1")) + run_command.assert_any_call( + ["git", "fetch", "origin", "release/mcp-v1"], + ) + run_command.assert_any_call( + ["git", "reset", "--hard", "origin/release/mcp-v1"], + ) + + +class ForcedIncrementTests(unittest.TestCase): + @mock.patch.object( + bump_package, + "run_command", + return_value=(0, "bump: keycardai-mcp 0.27.0 -> 1.0.0", ""), + ) + def test_forced_increment_is_forwarded_to_commitizen(self, run_command) -> None: + version = bump_package.cz_bump_files_only( + "packages/mcp", "keycardai-mcp", increment="major" + ) + self.assertEqual(version, "1.0.0") + command = run_command.call_args[0][0] + self.assertIn("--increment", command) + self.assertIn("MAJOR", command) + self.assertIn("--allow-no-commit", command) + + @mock.patch.object( + bump_package, + "run_command", + return_value=(0, "bump: keycardai-mcp 1.0.0 -> 1.0.1", ""), + ) + def test_auto_increment_leaves_commitizen_derivation_alone( + self, run_command + ) -> None: + version = bump_package.cz_bump_files_only("packages/mcp", "keycardai-mcp") + self.assertEqual(version, "1.0.1") + command = run_command.call_args[0][0] + self.assertNotIn("--increment", command) + self.assertNotIn("--allow-no-commit", command) + + +class PrBaseBranchTests(unittest.TestCase): + @mock.patch.object(bump_package, "wait_for_pr_stable", return_value=True) + @mock.patch.object( + bump_package, + "run_command", + side_effect=[ + # gh pr create + (0, "https://github.com/keycardai/python-sdk/pull/999", ""), + # gh pr merge --auto --squash + (0, "", ""), + ], + ) + def test_pr_targets_the_release_branch(self, run_command, _stable) -> None: + pr_number = bump_package.create_pr_with_automerge( + "bump/release-mcp-v1/keycardai-mcp-1.0.1", + "release/mcp-v1", + "keycardai-mcp", + "1.0.1", + ) + self.assertEqual(pr_number, 999) + command = run_command.call_args_list[0][0][0] + base_index = command.index("--base") + self.assertEqual(command[base_index + 1], "release/mcp-v1") + + +if __name__ == "__main__": + unittest.main()