Skip to content

Commit 91d041f

Browse files
zhaotaiclaude
andauthored
feat: Add TypeScript SDK generation and publishing pipeline (#83)
* inital commit * feat: Add TypeScript SDK generation and publishing pipeline - Add @hey-api/openapi-ts based SDK generation from Starlette OpenAPI schema - Create unified release workflow with separate jobs for Python and TypeScript - Add version validation to ensure Git tags match pyproject.toml - Configure SDK to use @llamaindex/workflows-client package name - Add CI workflow to validate SDK generation on every PR - Use pnpm as package manager for Node.js operations - Add pyyaml dependency for OpenAPI schema generation The SDK is treated as a build artifact, generated from the Python server's OpenAPI specification. Version synchronization is maintained by using pyproject.toml as the single source of truth. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <[email protected]> * fix lint * fix * rebase main * fix * fix * fix ci * refactor: Simplify to only publish OpenAPI spec as release artifact - Remove TypeScript SDK generation infrastructure - Add OpenAPI spec generation to release workflow - Add version validation to ensure tag matches pyproject.toml - Add trigger to notify llama-ui for SDK generation - Keep workflow changes minimal from original The SDK generation will now be handled in the llama-ui repository, which can fetch the OpenAPI spec from releases and generate the SDK. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <[email protected]> * feat: Add smart SDK update triggering with change type detection - Add workflow_dispatch inputs for manual server change type and description - Create script to auto-detect change type from semantic version tags - Add comprehensive tests using pytest-style functions with mock data - Only trigger SDK update when there are server/API changes - Pass change type and description to llama-ui for proper versioning - Use tomllib for proper TOML parsing in validate_version.py For manual triggers: developers can specify if there are API changes For tag pushes: automatically detects patch/minor/major from version bump Tests use temporary directories and mock data for complete isolation. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <[email protected]> * fix lint * fix: Replace tomllib with regex parsing for Python 3.9+ compatibility - Remove tomllib dependency which is only available in Python 3.11+ - Use regex to parse version from pyproject.toml for broader Python version support - Add test fixture cleanup to reduce event loop warnings in async tests * fix comment --------- Co-authored-by: Claude <[email protected]>
1 parent d09548d commit 91d041f

File tree

8 files changed

+629
-3
lines changed

8 files changed

+629
-3
lines changed

.github/workflows/publish_release.yml

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,21 @@ name: Publish to PyPI and GitHub
22

33
on:
44
workflow_dispatch:
5+
inputs:
6+
server_change_type:
7+
description: 'Server/API change type, this will be used to generate the client SDK'
8+
required: false
9+
default: 'none'
10+
type: choice
11+
options:
12+
- none
13+
- patch
14+
- minor
15+
- major
16+
change_description:
17+
description: 'Description of server/API changes (optional), this will be used to generate the client SDK'
18+
required: false
19+
type: string
520
push:
621
tags:
722
- "v*"
@@ -16,19 +31,59 @@ jobs:
1631

1732
steps:
1833
- uses: actions/checkout@v4
34+
with:
35+
fetch-depth: 0 # Need full history for tag comparison
1936

2037
- name: Install uv
2138
uses: astral-sh/setup-uv@v6
2239

40+
- name: Validate version
41+
if: startsWith(github.ref, 'refs/tags/')
42+
run: uv run python scripts/validate_version.py
43+
44+
- name: Set version output
45+
id: version
46+
run: echo "version=$(uv run python -c "from importlib.metadata import version; print(version('llama-index-workflows'))")" >> $GITHUB_OUTPUT
47+
2348
- name: Build and publish
2449
env:
2550
UV_PUBLISH_TOKEN: ${{ secrets.PYPI_TOKEN }}
2651
run: |
2752
uv build
2853
uv publish
2954
55+
- name: Generate OpenAPI spec
56+
run: |
57+
uv sync --extra server
58+
uv run hatch run server:openapi
59+
3060
- name: Create GitHub Release
3161
uses: ncipollo/release-action@v1
3262
with:
33-
artifacts: "dist/*"
63+
artifacts: "dist/*,openapi.json"
3464
generateReleaseNotes: true
65+
66+
- name: Detect change type for tags
67+
id: detect_change
68+
if: startsWith(github.ref, 'refs/tags/')
69+
run: uv run python scripts/detect_change_type.py
70+
71+
- name: Set SDK trigger parameters
72+
id: sdk_params
73+
run: |
74+
if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then
75+
echo "change_type=${{ github.event.inputs.server_change_type }}" >> $GITHUB_OUTPUT
76+
echo "change_description=${{ github.event.inputs.change_description }}" >> $GITHUB_OUTPUT
77+
else
78+
echo "change_type=${{ steps.detect_change.outputs.change_type }}" >> $GITHUB_OUTPUT
79+
echo "change_description=" >> $GITHUB_OUTPUT
80+
fi
81+
82+
- name: Trigger SDK Update
83+
if: steps.sdk_params.outputs.change_type != 'none' && steps.sdk_params.outputs.change_type != ''
84+
uses: peter-evans/repository-dispatch@v3
85+
with:
86+
token: ${{ secrets.LLAMA_UI_DISPATCH_TOKEN }}
87+
repository: run-llama/llama-ui
88+
event-type: workflows-sdk-update
89+
client-payload: '{"version": "${{ steps.version.outputs.version }}", "openapi_url": "https://github.com/run-llama/workflows-py/releases/download/v${{ steps.version.outputs.version }}/openapi.json", "change_type": "${{ steps.sdk_params.outputs.change_type }}", "change_description": "${{ steps.sdk_params.outputs.change_description }}"}'

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,3 +144,6 @@ cython_debug/
144144
.claude/
145145
.cursorignore
146146
.cursorindexingignore
147+
148+
# Generated files
149+
openapi.json

pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@ dev = [
1010
"pytest-cov>=6.1.1",
1111
"httpx>=0.25.0",
1212
"hatch>=1.14.1",
13-
"pyyaml>=6.0.2"
13+
"pyyaml>=6.0.2",
14+
"packaging>=21.0"
1415
]
1516

1617
[project]

scripts/detect_change_type.py

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
#!/usr/bin/env python3
2+
"""Detect the type of change based on version tags."""
3+
4+
import os
5+
import subprocess
6+
import sys
7+
from typing import Optional
8+
9+
from packaging.version import Version
10+
11+
12+
def get_previous_tag() -> Optional[str]:
13+
"""Get the previous release tag."""
14+
try:
15+
# Get all tags sorted by version
16+
result = subprocess.run(
17+
["git", "tag", "-l", "v*", "--sort=-version:refname"],
18+
capture_output=True,
19+
text=True,
20+
check=True,
21+
)
22+
tags = result.stdout.strip().split("\n")
23+
24+
# Current tag is the first one (if we're on a tag)
25+
current_ref = os.environ.get("GITHUB_REF", "")
26+
if current_ref.startswith("refs/tags/"):
27+
current_tag = current_ref.replace("refs/tags/", "")
28+
# Find current tag in list and return the next one
29+
if current_tag in tags:
30+
current_index = tags.index(current_tag)
31+
if current_index + 1 < len(tags):
32+
return tags[current_index + 1]
33+
34+
# If not on a tag or no previous tag found
35+
return tags[0] if tags else None
36+
except subprocess.CalledProcessError:
37+
return None
38+
39+
40+
def detect_change_type(current_version: str, previous_version: Optional[str]) -> str:
41+
"""Detect the type of change based on version comparison."""
42+
if not previous_version:
43+
# First release or no previous version
44+
return "major"
45+
46+
try:
47+
# Remove 'v' prefix if present
48+
current_clean = current_version.lstrip("v")
49+
previous_clean = previous_version.lstrip("v")
50+
51+
curr_version = Version(current_clean)
52+
prev_version = Version(previous_clean)
53+
54+
# Compare versions using packaging.version
55+
if curr_version <= prev_version:
56+
# Same or lower version (shouldn't happen in normal flow)
57+
return "none"
58+
59+
# Extract major.minor.patch components for semantic comparison
60+
curr_release = curr_version.release
61+
prev_release = prev_version.release
62+
63+
# Ensure we have at least 3 components (major, minor, patch)
64+
curr_major, curr_minor, curr_patch = (curr_release + (0, 0, 0))[:3]
65+
prev_major, prev_minor, prev_patch = (prev_release + (0, 0, 0))[:3]
66+
67+
if curr_major > prev_major:
68+
return "major"
69+
elif curr_minor > prev_minor:
70+
return "minor"
71+
elif curr_patch > prev_patch:
72+
return "patch"
73+
else:
74+
# This shouldn't happen if curr_version > prev_version
75+
return "minor"
76+
except Exception:
77+
# If we can't parse versions, default to minor
78+
return "minor"
79+
80+
81+
def main() -> None:
82+
"""Main function to detect change type."""
83+
# Get current tag from GITHUB_REF
84+
github_ref = os.environ.get("GITHUB_REF", "")
85+
if not github_ref.startswith("refs/tags/"):
86+
print("Not a tag push, no change type detection needed")
87+
sys.exit(0)
88+
89+
current_tag = github_ref.replace("refs/tags/", "")
90+
previous_tag = get_previous_tag()
91+
92+
change_type = detect_change_type(current_tag, previous_tag)
93+
94+
print(f"Current tag: {current_tag}")
95+
if previous_tag:
96+
print(f"Previous tag: {previous_tag}")
97+
else:
98+
print("No previous tag found")
99+
print(f"Change type: {change_type}")
100+
101+
# Output for GitHub Actions
102+
if github_output := os.environ.get("GITHUB_OUTPUT"):
103+
with open(github_output, "a") as f:
104+
f.write(f"change_type={change_type}\n")
105+
106+
107+
if __name__ == "__main__":
108+
main()

scripts/validate_version.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
#!/usr/bin/env python3
2+
"""Validate that release tag matches pyproject.toml version."""
3+
4+
import os
5+
import re
6+
import sys
7+
from pathlib import Path
8+
9+
from packaging.version import Version
10+
11+
12+
def get_pyproject_version() -> str:
13+
"""Extract version from pyproject.toml."""
14+
pyproject_path = Path(__file__).parent.parent / "pyproject.toml"
15+
with open(pyproject_path, "r") as f:
16+
content = f.read()
17+
# Look for version = "x.x.x" in the [project] section
18+
# First find the [project] section
19+
project_match = re.search(r"\[project\]", content)
20+
if not project_match:
21+
raise ValueError("Could not find [project] section in pyproject.toml")
22+
23+
# Then find the version line after [project]
24+
version_pattern = r'version\s*=\s*["\']([^"\']+)["\']'
25+
version_match = re.search(version_pattern, content[project_match.start() :])
26+
if not version_match:
27+
raise ValueError("Could not find version in pyproject.toml")
28+
29+
return version_match.group(1)
30+
31+
32+
def main() -> None:
33+
# Get pyproject.toml version
34+
try:
35+
pyproject_version = get_pyproject_version()
36+
except Exception as e:
37+
print(f"Error: {e}")
38+
sys.exit(1)
39+
40+
# Get tag from GitHub ref
41+
github_ref = os.environ.get("GITHUB_REF", "")
42+
if not github_ref.startswith("refs/tags/"):
43+
print("Error: Not a tag push")
44+
sys.exit(1)
45+
46+
tag = github_ref.replace("refs/tags/", "")
47+
tag_version = tag[1:] if tag.startswith("v") else tag
48+
49+
# Validate versions match using packaging.version for robust comparison
50+
try:
51+
pyproject_ver = Version(pyproject_version)
52+
tag_ver = Version(tag_version)
53+
54+
if pyproject_ver != tag_ver:
55+
print(
56+
f"Error: Tag {tag} (version {tag_version}) doesn't match pyproject.toml version {pyproject_version}"
57+
)
58+
sys.exit(1)
59+
60+
print(f"✅ Version validated: {pyproject_version} (tag: {tag})")
61+
except Exception as e:
62+
print(f"Error: Invalid version format - {e}")
63+
sys.exit(1)
64+
65+
66+
if __name__ == "__main__":
67+
main()

tests/server/test_server_endpoints.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,15 @@ async def async_client(
3232
transport = ASGITransport(app=server.app)
3333
yield AsyncClient(transport=transport, base_url="http://test")
3434

35+
# Clean up any running workflows to avoid event loop warnings
36+
for handler_id, handler in list(server._handlers.items()):
37+
if not handler.done():
38+
handler.cancel()
39+
try:
40+
await handler
41+
except asyncio.CancelledError:
42+
pass
43+
3544

3645
@pytest.mark.asyncio
3746
async def test_health_check(async_client: AsyncClient) -> None:

0 commit comments

Comments
 (0)