From 3a521713adc96a9849f949cfdd8e6af06dbdd77f Mon Sep 17 00:00:00 2001 From: Birdcar <434063+birdcar@users.noreply.github.com> Date: Thu, 15 Jan 2026 08:45:02 -0600 Subject: [PATCH 1/4] ci: Migrate release workflow to Trusted Publishing - Replace Twine/token-based auth with PyPI Trusted Publishing - Add smoke tests to verify wheel and sdist before publishing - Run smoke tests against all supported Python versions (3.8-3.14) - Use matrix strategy for parallel testing across versions - Use uv publish for streamlined publishing Workflow structure: 1. build: Create wheel and sdist artifacts 2. smoke-test: Test on Python 3.8-3.14 in parallel 3. publish: Upload to PyPI after all tests pass Smoke tests verify: - Package imports correctly - Both sync/async clients instantiate - All module properties accessible - Core types and exceptions importable - Dependencies properly bundled - py.typed marker present --- .github/workflows/release.yml | 62 ++++++-- tests/smoke_test.py | 270 ++++++++++++++++++++++++++++++++++ 2 files changed, 319 insertions(+), 13 deletions(-) create mode 100644 tests/smoke_test.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 289d792d..761bd0b2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -12,25 +12,61 @@ defaults: shell: bash jobs: - pypi: - name: Publish to PyPI + build: + name: Build distribution runs-on: ubuntu-latest - permissions: - contents: read steps: - name: Checkout uses: actions/checkout@v5 - name: Install uv uses: astral-sh/setup-uv@v6 - - name: Install dependencies - run: uv sync --locked - - name: Test - run: uv run pytest - name: Build run: uv build + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: dist + path: dist/ + + smoke-test: + name: Smoke test (Python ${{ matrix.python }}) + needs: build + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13", "3.14"] + steps: + - name: Checkout + uses: actions/checkout@v5 + - name: Install uv + uses: astral-sh/setup-uv@v6 + - name: Download artifacts + uses: actions/download-artifact@v4 + with: + name: dist + path: dist/ + - name: Smoke test (wheel) + run: uv run --python ${{ matrix.python }} --isolated --no-project --with dist/*.whl tests/smoke_test.py + - name: Smoke test (source distribution) + run: uv run --python ${{ matrix.python }} --isolated --no-project --with dist/*.tar.gz tests/smoke_test.py + + publish: + name: Publish to PyPI + needs: smoke-test + runs-on: ubuntu-latest + environment: + name: pypi + permissions: + id-token: write + contents: read + steps: + - name: Download artifacts + uses: actions/download-artifact@v4 + with: + name: dist + path: dist/ + - name: Install uv + uses: astral-sh/setup-uv@v6 - name: Publish - env: - TWINE_NON_INTERACTIVE: true - TWINE_USERNAME: "__token__" - TWINE_PASSWORD: ${{ secrets.TWINE_PASSWORD }} - run: uvx twine upload dist/* --skip-existing + run: uv publish diff --git a/tests/smoke_test.py b/tests/smoke_test.py new file mode 100644 index 00000000..aa4566ee --- /dev/null +++ b/tests/smoke_test.py @@ -0,0 +1,270 @@ +#!/usr/bin/env python3 +"""Smoke tests to verify the built package works correctly. + +These tests run against the installed package (wheel or sdist) to verify: +- All imports work correctly +- Dependencies are properly bundled +- Type markers are present +- Both sync and async clients can be instantiated +- All module properties are accessible + +Run with: uv run --isolated --no-project --with dist/*.whl tests/smoke_test.py +""" + +import sys +from pathlib import Path + + +def test_basic_import() -> None: + """Verify the package can be imported.""" + import workos + + assert workos is not None + print("✓ Basic import works") + + +def test_version_accessible() -> None: + """Verify version metadata is accessible.""" + from importlib.metadata import version + + pkg_version = version("workos") + assert pkg_version is not None + assert len(pkg_version) > 0 + print(f"✓ Version accessible: {pkg_version}") + + +def test_py_typed_marker() -> None: + """Verify py.typed marker is included for type checking support.""" + import workos + + package_path = Path(workos.__file__).parent + py_typed = package_path / "py.typed" + assert py_typed.exists(), f"py.typed marker not found at {py_typed}" + print(f"✓ py.typed marker present at {py_typed}") + + +def test_sync_client_import_and_instantiate() -> None: + """Verify sync client can be imported and instantiated.""" + from workos import WorkOSClient + + # Instantiate with dummy credentials (no API calls made) + client = WorkOSClient(api_key="sk_test_smoke", client_id="client_smoke") + assert client is not None + print("✓ WorkOSClient imports and instantiates") + + +def test_async_client_import_and_instantiate() -> None: + """Verify async client can be imported and instantiated.""" + from workos import AsyncWorkOSClient + + # Instantiate with dummy credentials (no API calls made) + client = AsyncWorkOSClient(api_key="sk_test_smoke", client_id="client_smoke") + assert client is not None + print("✓ AsyncWorkOSClient imports and instantiates") + + +def test_sync_client_modules_accessible() -> None: + """Verify all module properties are accessible on sync client.""" + from workos import WorkOSClient + + client = WorkOSClient(api_key="sk_test_smoke", client_id="client_smoke") + + modules = [ + "api_keys", + "audit_logs", + "directory_sync", + "events", + "fga", + "mfa", + "organizations", + "organization_domains", + "passwordless", + "pipes", + "portal", + "sso", + "user_management", + "vault", + "webhooks", + "widgets", + ] + + for module_name in modules: + module = getattr(client, module_name, None) + assert module is not None, f"Module {module_name} not accessible" + print(f" ✓ client.{module_name}") + + print(f"✓ All {len(modules)} sync client modules accessible") + + +def test_async_client_modules_accessible() -> None: + """Verify all module properties are accessible on async client. + + Note: Some modules raise NotImplementedError as they're not yet + supported in the async client. We verify the property exists and + raises the expected error. + """ + from workos import AsyncWorkOSClient + + client = AsyncWorkOSClient(api_key="sk_test_smoke", client_id="client_smoke") + + # Modules fully supported in async client + supported_modules = [ + "api_keys", + "directory_sync", + "events", + "organizations", + "organization_domains", + "pipes", + "sso", + "user_management", + ] + + # Modules that exist but raise NotImplementedError + not_implemented_modules = [ + "audit_logs", + "fga", + "mfa", + "passwordless", + "portal", + "vault", + "webhooks", + "widgets", + ] + + for module_name in supported_modules: + module = getattr(client, module_name, None) + assert module is not None, f"Module {module_name} not accessible" + print(f" ✓ async_client.{module_name}") + + for module_name in not_implemented_modules: + try: + getattr(client, module_name) + raise AssertionError( + f"Module {module_name} should raise NotImplementedError" + ) + except NotImplementedError: + print(f" ✓ async_client.{module_name} (not yet implemented)") + + total = len(supported_modules) + len(not_implemented_modules) + print(f"✓ All {total} async client modules verified") + + +def test_core_types_importable() -> None: + """Verify core type models can be imported.""" + # SSO types + from workos.types.sso import Connection, ConnectionDomain, Profile + + assert Connection is not None + assert ConnectionDomain is not None + assert Profile is not None + + # Organization types + from workos.types.organizations import Organization + + assert Organization is not None + + # Directory Sync types + from workos.types.directory_sync import Directory, DirectoryGroup, DirectoryUser + + assert Directory is not None + assert DirectoryGroup is not None + assert DirectoryUser is not None + + # User Management types + from workos.types.user_management import ( + AuthenticationResponse, + Invitation, + OrganizationMembership, + User, + ) + + assert AuthenticationResponse is not None + assert Invitation is not None + assert OrganizationMembership is not None + assert User is not None + + # Events types + from workos.types.events import Event + + assert Event is not None + + # FGA types + from workos.types.fga import Warrant, CheckResponse + + assert Warrant is not None + assert CheckResponse is not None + + print("✓ Core types importable") + + +def test_exceptions_importable() -> None: + """Verify exception classes can be imported.""" + from workos.exceptions import ( + AuthenticationException, + AuthorizationException, + BadRequestException, + ConflictException, + NotFoundException, + ServerException, + ) + + assert AuthenticationException is not None + assert AuthorizationException is not None + assert BadRequestException is not None + assert ConflictException is not None + assert NotFoundException is not None + assert ServerException is not None + + print("✓ Exception classes importable") + + +def test_dependencies_available() -> None: + """Verify core dependencies are installed and importable.""" + import httpx + import pydantic + import cryptography + import jwt + + print("✓ Core dependencies available (httpx, pydantic, cryptography, jwt)") + + +def main() -> int: + """Run all smoke tests.""" + print("=" * 60) + print("WorkOS Python SDK - Smoke Tests") + print("=" * 60) + print() + + tests = [ + test_basic_import, + test_version_accessible, + test_py_typed_marker, + test_sync_client_import_and_instantiate, + test_async_client_import_and_instantiate, + test_sync_client_modules_accessible, + test_async_client_modules_accessible, + test_core_types_importable, + test_exceptions_importable, + test_dependencies_available, + ] + + failed = 0 + for test in tests: + try: + test() + except Exception as e: + print(f"✗ {test.__name__} FAILED: {e}") + failed += 1 + print() + + print("=" * 60) + if failed == 0: + print(f"All {len(tests)} smoke tests passed!") + return 0 + else: + print(f"FAILED: {failed}/{len(tests)} tests failed") + return 1 + + +if __name__ == "__main__": + sys.exit(main()) From 2ee9d93375fa2ab8ca681542bc2435e6e133b69d Mon Sep 17 00:00:00 2001 From: Birdcar <434063+birdcar@users.noreply.github.com> Date: Thu, 15 Jan 2026 09:59:52 -0600 Subject: [PATCH 2/4] chore: ignore unused import lint errors in smoke test --- tests/smoke_test.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/smoke_test.py b/tests/smoke_test.py index aa4566ee..a4ede46c 100644 --- a/tests/smoke_test.py +++ b/tests/smoke_test.py @@ -11,6 +11,8 @@ Run with: uv run --isolated --no-project --with dist/*.whl tests/smoke_test.py """ +# ruff: noqa: F401 - imports are intentionally unused; this file tests import functionality + import sys from pathlib import Path From aaf293015ced76e4bfe720fdc42200f01a1ccbfa Mon Sep 17 00:00:00 2001 From: Birdcar <434063+birdcar@users.noreply.github.com> Date: Thu, 15 Jan 2026 10:09:30 -0600 Subject: [PATCH 3/4] ci: configure explicit Python version for smoke tests Pass python-version to setup-uv action to ensure the correct Python version is installed before running smoke tests. Enable cache-python to speed up subsequent workflow runs. --- .github/workflows/release.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 761bd0b2..5a42548e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -41,6 +41,9 @@ jobs: uses: actions/checkout@v5 - name: Install uv uses: astral-sh/setup-uv@v6 + with: + python-version: ${{ matrix.python }} + cache-python: true - name: Download artifacts uses: actions/download-artifact@v4 with: From d68cfeef5f552136e461c76a4800430020c1f000 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Wed, 21 Jan 2026 15:27:22 -0500 Subject: [PATCH 4/4] automate release flow --- .github/workflows/ci.yml | 23 ++++++++ .github/workflows/release.yml | 80 ++++++++++++--------------- .github/workflows/version-bump.yml | 86 ++++++++++++++++++++++++++++++ 3 files changed, 142 insertions(+), 47 deletions(-) create mode 100644 .github/workflows/version-bump.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8c5cd4c9..3504292f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,3 +39,26 @@ jobs: - name: Test run: uv run pytest + + smoke-test: + name: Smoke test (Python ${{ matrix.python }}) + if: github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'version-bump') + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13", "3.14"] + steps: + - uses: actions/checkout@v3 + - uses: astral-sh/setup-uv@v7 + with: + python-version: ${{ matrix.python }} + + - name: Build + run: uv build + + - name: Smoke test (wheel) + run: uv run --isolated --no-project --with dist/*.whl tests/smoke_test.py + + - name: Smoke test (sdist) + run: uv run --isolated --no-project --with dist/*.tar.gz tests/smoke_test.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5a42548e..ac1f3ece 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,75 +1,61 @@ name: Release on: - # Support manually pushing a new release - workflow_dispatch: {} - # Trigger when a release or pre-release is published - release: - types: [published] + pull_request: + types: [closed] + branches: [main] defaults: run: shell: bash jobs: - build: - name: Build distribution + create-release: + name: Create GitHub Release + if: github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'version-bump') runs-on: ubuntu-latest + permissions: + contents: write steps: - - name: Checkout - uses: actions/checkout@v5 - - name: Install uv - uses: astral-sh/setup-uv@v6 - - name: Build - run: uv build - - name: Upload artifacts - uses: actions/upload-artifact@v4 + - name: Generate token + id: generate-token + uses: actions/create-github-app-token@v1 with: - name: dist - path: dist/ + app-id: ${{ vars.WORKOS_BOT_APP_ID }} + private-key: ${{ secrets.WORKOS_BOT_PRIVATE_KEY }} - smoke-test: - name: Smoke test (Python ${{ matrix.python }}) - needs: build - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - python: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13", "3.14"] - steps: - name: Checkout - uses: actions/checkout@v5 - - name: Install uv - uses: astral-sh/setup-uv@v6 + uses: actions/checkout@v4 with: - python-version: ${{ matrix.python }} - cache-python: true - - name: Download artifacts - uses: actions/download-artifact@v4 + token: ${{ steps.generate-token.outputs.token }} + + - name: Get version from pyproject.toml + id: get-version + run: | + VERSION=$(grep '^version = ' pyproject.toml | sed 's/version = "\(.*\)"/\1/') + echo "version=$VERSION" >> $GITHUB_OUTPUT + + - name: Create Release + uses: softprops/action-gh-release@v2 with: - name: dist - path: dist/ - - name: Smoke test (wheel) - run: uv run --python ${{ matrix.python }} --isolated --no-project --with dist/*.whl tests/smoke_test.py - - name: Smoke test (source distribution) - run: uv run --python ${{ matrix.python }} --isolated --no-project --with dist/*.tar.gz tests/smoke_test.py + tag_name: v${{ steps.get-version.outputs.version }} + name: v${{ steps.get-version.outputs.version }} + generate_release_notes: true + token: ${{ steps.generate-token.outputs.token }} publish: name: Publish to PyPI - needs: smoke-test + needs: create-release runs-on: ubuntu-latest - environment: - name: pypi permissions: id-token: write contents: read steps: - - name: Download artifacts - uses: actions/download-artifact@v4 - with: - name: dist - path: dist/ + - name: Checkout + uses: actions/checkout@v4 - name: Install uv uses: astral-sh/setup-uv@v6 + - name: Build + run: uv build - name: Publish run: uv publish diff --git a/.github/workflows/version-bump.yml b/.github/workflows/version-bump.yml new file mode 100644 index 00000000..5ddb9ca7 --- /dev/null +++ b/.github/workflows/version-bump.yml @@ -0,0 +1,86 @@ +name: Version Bump + +on: + workflow_dispatch: + inputs: + bump_type: + description: "Version bump type" + required: true + type: choice + options: + - patch + - minor + - major + +jobs: + bump-version: + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + steps: + - name: Generate token + id: generate-token + uses: actions/create-github-app-token@v1 + with: + app-id: ${{ vars.WORKOS_BOT_APP_ID }} + private-key: ${{ secrets.WORKOS_BOT_PRIVATE_KEY }} + + - name: Checkout + uses: actions/checkout@v4 + with: + token: ${{ steps.generate-token.outputs.token }} + + - name: Configure Git + run: | + git config user.name "workos-bot[bot]" + git config user.email "workos-bot[bot]@users.noreply.github.com" + + - name: Read current version + id: current-version + run: | + CURRENT_VERSION=$(grep '^version = ' pyproject.toml | sed 's/version = "\(.*\)"/\1/') + echo "version=$CURRENT_VERSION" >> $GITHUB_OUTPUT + + - name: Bump version + id: bump-version + run: | + CURRENT_VERSION="${{ steps.current-version.outputs.version }}" + IFS='.' read -r MAJOR MINOR PATCH <<< "$CURRENT_VERSION" + + case "${{ github.event.inputs.bump_type }}" in + major) + NEW_VERSION="$((MAJOR + 1)).0.0" + ;; + minor) + NEW_VERSION="$MAJOR.$((MINOR + 1)).0" + ;; + patch) + NEW_VERSION="$MAJOR.$MINOR.$((PATCH + 1))" + ;; + esac + + echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT + + - name: Install uv + uses: astral-sh/setup-uv@v6 + + - name: Update version in pyproject.toml + run: | + sed -i 's/^version = ".*"/version = "${{ steps.bump-version.outputs.new_version }}"/' pyproject.toml + + - name: Update uv.lock + run: uv lock + + - name: Create Pull Request + uses: peter-evans/create-pull-request@v7 + with: + token: ${{ steps.generate-token.outputs.token }} + commit-message: "v${{ steps.bump-version.outputs.new_version }}" + title: "v${{ steps.bump-version.outputs.new_version }}" + body: | + Bumps version from ${{ steps.current-version.outputs.version }} to ${{ steps.bump-version.outputs.new_version }}. + + This PR was automatically created by the version-bump workflow. + branch: version-bump-${{ steps.bump-version.outputs.new_version }} + labels: version-bump