From 52176da156f1220b6dbf7f58d4bacdfeb81c6a85 Mon Sep 17 00:00:00 2001 From: Pavol Madeja Date: Wed, 25 Mar 2026 14:12:26 +0100 Subject: [PATCH 1/3] Library version check functionality --- .../polyapi-update-python-package.yml | 220 ++++++++++-------- polyapi/cli.py | 3 + polyapi/version_check.py | 195 ++++++++++++++++ tests/test_version_check.py | 128 ++++++++++ 4 files changed, 455 insertions(+), 91 deletions(-) create mode 100644 polyapi/version_check.py create mode 100644 tests/test_version_check.py diff --git a/.github/workflows/polyapi-update-python-package.yml b/.github/workflows/polyapi-update-python-package.yml index 1612bb8..6730101 100644 --- a/.github/workflows/polyapi-update-python-package.yml +++ b/.github/workflows/polyapi-update-python-package.yml @@ -1,4 +1,5 @@ name: Update python pip package + on: push: paths: @@ -6,106 +7,143 @@ on: branches: - develop - main + pull_request: + types: [closed] + paths: + - "pyproject.toml" + branches: + - develop + - main + - eu1 + - na2 jobs: - develop-build: - name: Build distribution 📦 - runs-on: ubuntu-latest - if: ${{ github.ref == 'refs/heads/develop' }} - environment: dev - - steps: - - uses: actions/checkout@v4 - - name: Set up Python - uses: actions/setup-python@v4 - with: - python-version: "3.x" - - - - name: Install pypa/build - run: >- - python3 -m - pip install - build - --user - - name: Build a binary wheel and a source tarball - run: python3 -m build - - name: Store the distribution packages - uses: actions/upload-artifact@v4 - with: - name: python-package-distributions - path: dist/ - - - dev-publish-to-pypi: - name: Publish Python 🐍 distribution 📦 to PyPI - runs-on: ubuntu-latest - if: ${{ github.ref == 'refs/heads/develop' }} - needs: develop-build - environment: - name: dev - url: https://pypi.org/p/polyapi-python - - permissions: - id-token: write - steps: - - - name: Download all the dists - uses: actions/download-artifact@v4 - with: - name: python-package-distributions - path: dist/ - - name: Publish distribution 📦 to PyPI - uses: pypa/gh-action-pypi-publish@release/v1 - - - main-build: + build-distribution: name: Build distribution 📦 runs-on: ubuntu-latest - if: ${{ github.ref == 'refs/heads/main' }} - environment: main steps: - - uses: actions/checkout@v4 - - name: Set up Python - uses: actions/setup-python@v4 - with: - python-version: "3.x" - - - name: Install pypa/build - run: >- - python3 -m - pip install - build - --user - - name: Build a binary wheel and a source tarball - run: python3 -m build - - name: Store the distribution packages - uses: actions/upload-artifact@v4 - with: - name: python-package-distributions - path: dist/ - - - main-publish-to-pypi: - name: >- - Publish Python 🐍 distribution 📦 to PyPI - if: ${{ github.ref == 'refs/heads/main' }} - needs: - - main-build + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.x" + + - name: Install build tooling + run: python -m pip install --upgrade pip build tomli twine + + - name: Build a binary wheel and a source tarball + run: python -m build + + - name: Store the distribution packages + uses: actions/upload-artifact@v4 + with: + name: python-package-distributions + path: dist/ + + - name: Resolve package version + id: pkg + shell: bash + run: | + VERSION=$(python -c "import pathlib, tomli; data = tomli.loads(pathlib.Path('pyproject.toml').read_text(encoding='utf-8')); print(data['project']['version'])") + echo "version=${VERSION}" >> "$GITHUB_OUTPUT" + echo "Resolved package version: ${VERSION}" + + outputs: + package_version: ${{ steps.pkg.outputs.version }} + + publish-and-update-client-versions: + name: Publish to PyPI and update BE client-versions runs-on: ubuntu-latest - environment: - name: main - url: https://pypi.org/p/polyapi-python + needs: build-distribution permissions: id-token: write + if: >- + ${{ + github.ref == 'refs/heads/develop' || + github.ref == 'refs/heads/main' || + ( + github.event_name == 'pull_request' && + github.event.pull_request.merged == true && + ( + (github.event.pull_request.head.ref == 'develop' && github.event.pull_request.base.ref == 'main') || + (github.event.pull_request.head.ref == 'main' && github.event.pull_request.base.ref == 'na2') || + (github.event.pull_request.head.ref == 'main' && github.event.pull_request.base.ref == 'eu1') + ) + ) + }} steps: - - name: Download all the dists - uses: actions/download-artifact@v4 - with: - name: python-package-distributions - path: dist/ - - name: Publish distribution 📦 to PyPI - uses: pypa/gh-action-pypi-publish@release/v1 + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Download all dists + uses: actions/download-artifact@v4 + with: + name: python-package-distributions + path: dist/ + + - name: Resolve instance and BE URL + id: target + shell: bash + run: | + if [[ "${GITHUB_EVENT_NAME}" == "push" ]]; then + if [[ "${GITHUB_REF}" == "refs/heads/develop" ]]; then + INSTANCE="develop" + BE_URL="https://dev.polyapi.io" + elif [[ "${GITHUB_REF}" == "refs/heads/main" ]]; then + INSTANCE="na1" + BE_URL="https://na1.polyapi.io" + else + echo "Unsupported push target: ${GITHUB_REF}" + exit 1 + fi + elif [[ "${GITHUB_EVENT_NAME}" == "pull_request" ]]; then + HEAD="${{ github.event.pull_request.head.ref }}" + BASE="${{ github.event.pull_request.base.ref }}" + if [[ "${HEAD}" == "develop" && "${BASE}" == "main" ]]; then + INSTANCE="na1" + BE_URL="https://na1.polyapi.io" + elif [[ "${HEAD}" == "main" && "${BASE}" == "na2" ]]; then + INSTANCE="na2" + BE_URL="https://na2.polyapi.io" + elif [[ "${HEAD}" == "main" && "${BASE}" == "eu1" ]]; then + INSTANCE="eu1" + BE_URL="https://eu1.polyapi.io" + else + echo "Unsupported path for publishing: ${HEAD} -> ${BASE}" + exit 1 + fi + else + echo "Unsupported event: ${GITHUB_EVENT_NAME}" + exit 1 + fi + + echo "instance=${INSTANCE}" >> "$GITHUB_OUTPUT" + echo "be_url=${BE_URL}" >> "$GITHUB_OUTPUT" + + - name: Publish distribution 📦 to PyPI + id: publish + uses: pypa/gh-action-pypi-publish@release/v1 + + - name: Update backend client-versions + if: ${{ success() }} + env: + BE_URL: ${{ steps.target.outputs.be_url }} + INSTANCE: ${{ steps.target.outputs.instance }} + VERSION: ${{ needs.build-distribution.outputs.package_version }} + POLY_SUPER_ADMIN_USER_KEY: ${{ secrets.POLY_SUPER_ADMIN_USER_KEY }} + shell: bash + run: | + if [[ -z "${POLY_SUPER_ADMIN_USER_KEY}" ]]; then + echo "Missing required secret POLY_SUPER_ADMIN_USER_KEY" + exit 1 + fi + + curl --fail-with-body -X POST \ + "${BE_URL}/client-versions" \ + -H "Authorization: Bearer ${POLY_SUPER_ADMIN_USER_KEY}" \ + -H "Content-Type: application/json" \ + -d "{\"python\":\"${VERSION}\"}" diff --git a/polyapi/cli.py b/polyapi/cli.py index 2187276..1d96bb0 100644 --- a/polyapi/cli.py +++ b/polyapi/cli.py @@ -10,6 +10,7 @@ from .rendered_spec import get_and_update_rendered_spec from .prepare import prepare_deployables from .sync import sync_deployables +from .version_check import check_for_client_version_update def _get_version_string(): @@ -23,6 +24,8 @@ def _get_version_string(): def execute_from_cli(): + check_for_client_version_update() + # First we setup all our argument parsing logic # Then we parse the arguments (waaay at the bottom) parser = argparse.ArgumentParser( diff --git a/polyapi/version_check.py b/polyapi/version_check.py new file mode 100644 index 0000000..7b9460a --- /dev/null +++ b/polyapi/version_check.py @@ -0,0 +1,195 @@ +import importlib.metadata +import logging +import os +import re +import subprocess +import sys +from urllib.parse import urlparse + +from packaging.version import InvalidVersion, Version + +from .config import get_api_key_and_url +from .http_client import get as http_get + +logger = logging.getLogger("poly") + +_REEXEC_GUARD_ENV = "POLY_VERSION_REEXEC_GUARD" + + +def _get_client_version() -> str | None: + try: + return importlib.metadata.version("polyapi-python") + except Exception: + return None + + +def _normalize_version(version: str | None) -> Version | None: + if not version: + return None + + candidate = version.strip() + if not candidate: + return None + + try: + return Version(candidate) + except InvalidVersion: + pass + + # best-effort coercion similar to semver.coerce: + # pull first numeric dotted segment with optional pre-release/dev suffix + match = re.search(r"(\d+(?:\.\d+){0,2}(?:[a-zA-Z]+\d*)?)", candidate) + if not match: + return None + + try: + return Version(match.group(1)) + except InvalidVersion: + return None + + +def get_instance_tag_from_base_url(base_url: str | None) -> str | None: + if not base_url: + return None + + try: + host = urlparse(base_url).hostname + if not host: + return None + host_prefix = host.lower().split(".")[0] + + if host_prefix in ("dev", "develop"): + return "develop" + if host_prefix == "staging": + return "staging" + if host_prefix == "test": + return "test" + if host_prefix == "na2": + return "na2" + if host_prefix == "eu1": + return "eu1" + return "na1" + except Exception: + return None + + +def _resolve_instance_tag(base_url: str | None) -> str | None: + explicit = os.getenv("POLY_INSTANCE_TAG") + if explicit: + return explicit.strip() + return get_instance_tag_from_base_url(base_url) + + +def _get_client_versions(base_url: str) -> dict: + url = f"{base_url.rstrip('/')}/client-versions" + resp = http_get(url, timeout=5.0) + resp.raise_for_status() + payload = resp.json() + if not isinstance(payload, dict): + raise ValueError("Invalid client-versions payload, expected object.") + return payload + + +def _resolve_target_version(versions_payload: dict) -> str | None: + # Per-instance response shape: { "python": "1.14.5", "typescript": "1.20.0" } + if isinstance(versions_payload, dict): + value = versions_payload.get("python") + if isinstance(value, str) and value.strip(): + return value.strip() + return None + + +def _run_update(target_version: str) -> bool: + cmd = [ + sys.executable, + "-m", + "pip", + "install", + "--upgrade", + f"polyapi-python=={target_version}", + ] + try: + subprocess.run(cmd, check=True) + return True + except Exception as ex: + logger.error("Failed to update polyapi-python to %s: %s", target_version, ex) + return False + + +def _reexec_process(base_url: str | None = None, api_key: str | None = None) -> None: + os.environ[_REEXEC_GUARD_ENV] = "1" + + # preserve already-resolved config values so initialize_config won't prompt again + if base_url: + os.environ["POLY_API_BASE_URL"] = base_url + if api_key: + os.environ["POLY_API_KEY"] = api_key + + argv = [sys.executable, "-m", "polyapi", *sys.argv[1:]] + os.execv(sys.executable, argv) + + +def check_for_client_version_update() -> None: + # prevent update/re-exec loops + if os.getenv(_REEXEC_GUARD_ENV) == "1": + return + + # Reuse existing config flow for base URL retrieval + api_key, base_url = get_api_key_and_url() + base_url = base_url or os.getenv("POLY_API_BASE_URL") + if not base_url: + return + + instance_tag = _resolve_instance_tag(base_url) + if not instance_tag: + return + + current_version = _get_client_version() + normalized_current = _normalize_version(current_version) + if not current_version or not normalized_current: + return + + try: + versions_payload = _get_client_versions(base_url) + except Exception as ex: + logger.error("Failed to fetch client versions from backend: %s", ex) + return + + available_version = _resolve_target_version(versions_payload) + normalized_available = _normalize_version(available_version) + if not available_version or not normalized_available: + return + + using_older_version = normalized_current < normalized_available + using_newer_version = normalized_current > normalized_available # noqa: F841 + + if not using_older_version and not using_newer_version: + return + + warning_message = ( + f'Instance "{instance_tag}" uses ' + f'{"a later" if using_older_version else "an older"} version of the Poly client. ' + f"Current: {current_version}, Instance: {available_version}." + ) + + non_interactive_mode = bool( + os.getenv("POLY_API_KEY") or os.getenv("POLY_API_BASE_URL") + ) + + if non_interactive_mode: + print(f"{warning_message} Please update to avoid any issues.") + return + + should_update_input = input(f"{warning_message} Update now? [Y/n]: ").strip().lower() + should_update = should_update_input in ("", "y", "yes") + + if should_update: + updated = _run_update(available_version) + if updated: + _reexec_process(base_url=base_url, api_key=api_key) + return + + print( + f'Continuing with {"older" if using_older_version else "newer"} ' + f"Poly client version {current_version}. Please update to avoid any issues." + ) \ No newline at end of file diff --git a/tests/test_version_check.py b/tests/test_version_check.py new file mode 100644 index 0000000..b993a4e --- /dev/null +++ b/tests/test_version_check.py @@ -0,0 +1,128 @@ +import os + +from polyapi import version_check + + +class _FakeResponse: + def __init__(self, payload): + self._payload = payload + + def raise_for_status(self): + return None + + def json(self): + return self._payload + + +def test_get_instance_tag_from_base_url(): + assert version_check.get_instance_tag_from_base_url("https://dev.polyapi.io") == "develop" + assert version_check.get_instance_tag_from_base_url("https://develop.polyapi.io") == "develop" + assert version_check.get_instance_tag_from_base_url("https://staging.polyapi.io") == "staging" + assert version_check.get_instance_tag_from_base_url("https://test.polyapi.io") == "test" + assert version_check.get_instance_tag_from_base_url("https://na2.polyapi.io") == "na2" + assert version_check.get_instance_tag_from_base_url("https://eu1.polyapi.io") == "eu1" + assert version_check.get_instance_tag_from_base_url("https://na1.polyapi.io") == "na1" + assert version_check.get_instance_tag_from_base_url("not-a-url") is None + + +def test_check_version_non_interactive_warns(monkeypatch, capsys): + monkeypatch.delenv("POLY_VERSION_REEXEC_GUARD", raising=False) + monkeypatch.setenv("POLY_API_KEY", "k") + monkeypatch.setenv("POLY_API_BASE_URL", "https://eu1.polyapi.io") + + monkeypatch.setattr(version_check, "get_api_key_and_url", lambda: ("k", "https://eu1.polyapi.io")) + monkeypatch.setattr(version_check, "_get_client_version", lambda: "1.0.0") + monkeypatch.setattr( + version_check, + "http_get", + lambda *args, **kwargs: _FakeResponse({"python": "1.2.0", "typescript": "1.20"}), + ) + + version_check.check_for_client_version_update() + out = capsys.readouterr().out + assert 'Instance "eu1" uses a later version of the Poly client. Current: 1.0.0, Instance: 1.2.0.' in out + assert "Please update to avoid any issues." in out + + +def test_check_version_interactive_decline(monkeypatch, capsys): + monkeypatch.delenv("POLY_VERSION_REEXEC_GUARD", raising=False) + monkeypatch.delenv("POLY_API_KEY", raising=False) + monkeypatch.delenv("POLY_API_BASE_URL", raising=False) + + monkeypatch.setattr(version_check, "get_api_key_and_url", lambda: ("k", "https://eu1.polyapi.io")) + monkeypatch.setattr(version_check, "_get_client_version", lambda: "1.0.0") + monkeypatch.setattr( + version_check, + "http_get", + lambda *args, **kwargs: _FakeResponse({"python": "1.2.0", "typescript": "1.20.0"}), + ) + monkeypatch.setattr("builtins.input", lambda *_args, **_kwargs: "n") + + version_check.check_for_client_version_update() + out = capsys.readouterr().out + assert "Continuing with older Poly client version 1.0.0. Please update to avoid any issues." in out + + +def test_check_version_interactive_accept_updates_and_reexec(monkeypatch): + monkeypatch.delenv("POLY_VERSION_REEXEC_GUARD", raising=False) + monkeypatch.delenv("POLY_API_KEY", raising=False) + monkeypatch.delenv("POLY_API_BASE_URL", raising=False) + + monkeypatch.setattr(version_check, "get_api_key_and_url", lambda: ("k", "https://eu1.polyapi.io")) + monkeypatch.setattr(version_check, "_get_client_version", lambda: "1.0.0") + monkeypatch.setattr( + version_check, + "http_get", + lambda *args, **kwargs: _FakeResponse({"python": "1.2.0", "typescript": "1.20.0"}), + ) + monkeypatch.setattr("builtins.input", lambda *_args, **_kwargs: "y") + + state = {"updated": False, "reexec": False} + + def _fake_run_update(_v): + state["updated"] = True + return True + + def _fake_reexec(*args, **kwargs): + state["reexec"] = True + + monkeypatch.setattr(version_check, "_run_update", _fake_run_update) + monkeypatch.setattr(version_check, "_reexec_process", _fake_reexec) + + version_check.check_for_client_version_update() + + assert state["updated"] is True + assert state["reexec"] is True + + +def test_check_skips_when_python_version_missing(monkeypatch, capsys): + monkeypatch.delenv("POLY_VERSION_REEXEC_GUARD", raising=False) + monkeypatch.setenv("POLY_API_KEY", "k") + monkeypatch.setenv("POLY_API_BASE_URL", "https://na2.polyapi.io") + + monkeypatch.setattr(version_check, "get_api_key_and_url", lambda: ("k", "https://na2.polyapi.io")) + monkeypatch.setattr(version_check, "_get_client_version", lambda: "1.0.0") + monkeypatch.setattr( + version_check, + "http_get", + lambda *args, **kwargs: _FakeResponse({"typescript": "1.20"}), + ) + + version_check.check_for_client_version_update() + out = capsys.readouterr().out + assert out == "" + + +def test_reexec_guard_prevents_flow(monkeypatch): + monkeypatch.setenv("POLY_VERSION_REEXEC_GUARD", "1") + called = {"get_api": False} + + def _fake_get_api_key_and_url(): + called["get_api"] = True + return ("k", "https://eu1.polyapi.io") + + monkeypatch.setattr(version_check, "get_api_key_and_url", _fake_get_api_key_and_url) + version_check.check_for_client_version_update() + assert called["get_api"] is False + + monkeypatch.delenv("POLY_VERSION_REEXEC_GUARD", raising=False) \ No newline at end of file From ef4e60323c5a7a2011d3617d0d7ea33eeda90cce Mon Sep 17 00:00:00 2001 From: Pavol Madeja Date: Wed, 25 Mar 2026 14:46:46 +0100 Subject: [PATCH 2/3] Instance specific workflows --- .../polyapi-update-python-package.yml | 172 +++++++++++++----- 1 file changed, 123 insertions(+), 49 deletions(-) diff --git a/.github/workflows/polyapi-update-python-package.yml b/.github/workflows/polyapi-update-python-package.yml index 6730101..ce1412a 100644 --- a/.github/workflows/polyapi-update-python-package.yml +++ b/.github/workflows/polyapi-update-python-package.yml @@ -53,97 +53,171 @@ jobs: outputs: package_version: ${{ steps.pkg.outputs.version }} - publish-and-update-client-versions: - name: Publish to PyPI and update BE client-versions + publish-develop: + name: Publish to PyPI and update develop client-versions runs-on: ubuntu-latest needs: build-distribution + environment: develop permissions: id-token: write + if: ${{ github.ref == 'refs/heads/develop' }} + steps: + - name: Download all dists + uses: actions/download-artifact@v4 + with: + name: python-package-distributions + path: dist/ + + - name: Publish distribution 📦 to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + + - name: Update backend client-versions + env: + BASE_URL: https://dev.polyapi.io + VERSION: ${{ needs.build-distribution.outputs.package_version }} + POLY_SUPER_ADMIN_USER_KEY: ${{ secrets.POLY_SUPER_ADMIN_USER_KEY }} + shell: bash + run: | + if [[ -z "${POLY_SUPER_ADMIN_USER_KEY}" ]]; then + echo "Missing required secret POLY_SUPER_ADMIN_USER_KEY in environment 'develop'" + exit 1 + fi + + curl --fail-with-body -X POST \ + "${BASE_URL}/client-versions" \ + -H "Authorization: Bearer ${POLY_SUPER_ADMIN_USER_KEY}" \ + -H "Content-Type: application/json" \ + -d "{\"python\":\"${VERSION}\"}" + + publish-na1: + name: Publish to PyPI and update na1 BE client-versions + runs-on: ubuntu-latest + needs: build-distribution + environment: na1 + permissions: + id-token: write if: >- ${{ - github.ref == 'refs/heads/develop' || github.ref == 'refs/heads/main' || ( github.event_name == 'pull_request' && github.event.pull_request.merged == true && - ( - (github.event.pull_request.head.ref == 'develop' && github.event.pull_request.base.ref == 'main') || - (github.event.pull_request.head.ref == 'main' && github.event.pull_request.base.ref == 'na2') || - (github.event.pull_request.head.ref == 'main' && github.event.pull_request.base.ref == 'eu1') - ) + github.event.pull_request.head.ref == 'develop' && + github.event.pull_request.base.ref == 'main' ) }} steps: - - name: Checkout repository - uses: actions/checkout@v4 + - name: Download all dists + uses: actions/download-artifact@v4 + with: + name: python-package-distributions + path: dist/ + + - name: Publish distribution 📦 to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + + - name: Update backend client-versions + env: + BASE_URL: https://na1.polyapi.io + VERSION: ${{ needs.build-distribution.outputs.package_version }} + POLY_SUPER_ADMIN_USER_KEY: ${{ secrets.POLY_SUPER_ADMIN_USER_KEY }} + shell: bash + run: | + if [[ -z "${POLY_SUPER_ADMIN_USER_KEY}" ]]; then + echo "Missing required secret POLY_SUPER_ADMIN_USER_KEY in environment 'na1'" + exit 1 + fi + + curl --fail-with-body -X POST \ + "${BASE_URL}/client-versions" \ + -H "Authorization: Bearer ${POLY_SUPER_ADMIN_USER_KEY}" \ + -H "Content-Type: application/json" \ + -d "{\"python\":\"${VERSION}\"}" + + publish-na2: + name: Publish to PyPI and update na2 client-versions + runs-on: ubuntu-latest + needs: build-distribution + environment: na2 + permissions: + id-token: write + if: >- + ${{ + github.event_name == 'pull_request' && + github.event.pull_request.merged == true && + github.event.pull_request.head.ref == 'main' && + github.event.pull_request.base.ref == 'na2' + }} + steps: - name: Download all dists uses: actions/download-artifact@v4 with: name: python-package-distributions path: dist/ - - name: Resolve instance and BE URL - id: target + - name: Publish distribution 📦 to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + + - name: Update backend client-versions + env: + BASE_URL: https://na2.polyapi.io + VERSION: ${{ needs.build-distribution.outputs.package_version }} + POLY_SUPER_ADMIN_USER_KEY: ${{ secrets.POLY_SUPER_ADMIN_USER_KEY }} shell: bash run: | - if [[ "${GITHUB_EVENT_NAME}" == "push" ]]; then - if [[ "${GITHUB_REF}" == "refs/heads/develop" ]]; then - INSTANCE="develop" - BE_URL="https://dev.polyapi.io" - elif [[ "${GITHUB_REF}" == "refs/heads/main" ]]; then - INSTANCE="na1" - BE_URL="https://na1.polyapi.io" - else - echo "Unsupported push target: ${GITHUB_REF}" - exit 1 - fi - elif [[ "${GITHUB_EVENT_NAME}" == "pull_request" ]]; then - HEAD="${{ github.event.pull_request.head.ref }}" - BASE="${{ github.event.pull_request.base.ref }}" - if [[ "${HEAD}" == "develop" && "${BASE}" == "main" ]]; then - INSTANCE="na1" - BE_URL="https://na1.polyapi.io" - elif [[ "${HEAD}" == "main" && "${BASE}" == "na2" ]]; then - INSTANCE="na2" - BE_URL="https://na2.polyapi.io" - elif [[ "${HEAD}" == "main" && "${BASE}" == "eu1" ]]; then - INSTANCE="eu1" - BE_URL="https://eu1.polyapi.io" - else - echo "Unsupported path for publishing: ${HEAD} -> ${BASE}" - exit 1 - fi - else - echo "Unsupported event: ${GITHUB_EVENT_NAME}" + if [[ -z "${POLY_SUPER_ADMIN_USER_KEY}" ]]; then + echo "Missing required secret POLY_SUPER_ADMIN_USER_KEY in environment 'na2'" exit 1 fi - echo "instance=${INSTANCE}" >> "$GITHUB_OUTPUT" - echo "be_url=${BE_URL}" >> "$GITHUB_OUTPUT" + curl --fail-with-body -X POST \ + "${BASE_URL}/client-versions" \ + -H "Authorization: Bearer ${POLY_SUPER_ADMIN_USER_KEY}" \ + -H "Content-Type: application/json" \ + -d "{\"python\":\"${VERSION}\"}" + + publish-eu1: + name: Publish to PyPI and update eu1 BE client-versions + runs-on: ubuntu-latest + needs: build-distribution + environment: eu1 + permissions: + id-token: write + if: >- + ${{ + github.event_name == 'pull_request' && + github.event.pull_request.merged == true && + github.event.pull_request.head.ref == 'main' && + github.event.pull_request.base.ref == 'eu1' + }} + + steps: + - name: Download all dists + uses: actions/download-artifact@v4 + with: + name: python-package-distributions + path: dist/ - name: Publish distribution 📦 to PyPI - id: publish uses: pypa/gh-action-pypi-publish@release/v1 - name: Update backend client-versions - if: ${{ success() }} env: - BE_URL: ${{ steps.target.outputs.be_url }} - INSTANCE: ${{ steps.target.outputs.instance }} + BASE_URL: https://eu1.polyapi.io VERSION: ${{ needs.build-distribution.outputs.package_version }} POLY_SUPER_ADMIN_USER_KEY: ${{ secrets.POLY_SUPER_ADMIN_USER_KEY }} shell: bash run: | if [[ -z "${POLY_SUPER_ADMIN_USER_KEY}" ]]; then - echo "Missing required secret POLY_SUPER_ADMIN_USER_KEY" + echo "Missing required secret POLY_SUPER_ADMIN_USER_KEY in environment 'eu1'" exit 1 fi curl --fail-with-body -X POST \ - "${BE_URL}/client-versions" \ + "${BASE_URL}/client-versions" \ -H "Authorization: Bearer ${POLY_SUPER_ADMIN_USER_KEY}" \ -H "Content-Type: application/json" \ -d "{\"python\":\"${VERSION}\"}" From 69953e555f04d708998b5fdaf573a9c119c05235 Mon Sep 17 00:00:00 2001 From: Pavol Madeja Date: Thu, 26 Mar 2026 15:21:16 +0100 Subject: [PATCH 3/3] Use config variable for version management --- .../polyapi-update-python-package.yml | 24 +++++++++---------- polyapi/version_check.py | 24 ++++++++++++------- 2 files changed, 27 insertions(+), 21 deletions(-) diff --git a/.github/workflows/polyapi-update-python-package.yml b/.github/workflows/polyapi-update-python-package.yml index ce1412a..efb838c 100644 --- a/.github/workflows/polyapi-update-python-package.yml +++ b/.github/workflows/polyapi-update-python-package.yml @@ -84,11 +84,11 @@ jobs: exit 1 fi - curl --fail-with-body -X POST \ - "${BASE_URL}/client-versions" \ + curl --fail-with-body -X PATCH \ + "${BASE_URL}/config-variables/SupportedClientVersions" \ -H "Authorization: Bearer ${POLY_SUPER_ADMIN_USER_KEY}" \ -H "Content-Type: application/json" \ - -d "{\"python\":\"${VERSION}\"}" + -d "{\"name\":\"SupportedClientVersions\",\"value\":{\"python\":\"${VERSION}\"}}" publish-na1: name: Publish to PyPI and update na1 BE client-versions @@ -130,11 +130,11 @@ jobs: exit 1 fi - curl --fail-with-body -X POST \ - "${BASE_URL}/client-versions" \ + curl --fail-with-body -X PATCH \ + "${BASE_URL}/config-variables/SupportedClientVersions" \ -H "Authorization: Bearer ${POLY_SUPER_ADMIN_USER_KEY}" \ -H "Content-Type: application/json" \ - -d "{\"python\":\"${VERSION}\"}" + -d "{\"name\":\"SupportedClientVersions\",\"value\":{\"python\":\"${VERSION}\"}}" publish-na2: name: Publish to PyPI and update na2 client-versions @@ -173,11 +173,11 @@ jobs: exit 1 fi - curl --fail-with-body -X POST \ - "${BASE_URL}/client-versions" \ + curl --fail-with-body -X PATCH \ + "${BASE_URL}/config-variables/SupportedClientVersions" \ -H "Authorization: Bearer ${POLY_SUPER_ADMIN_USER_KEY}" \ -H "Content-Type: application/json" \ - -d "{\"python\":\"${VERSION}\"}" + -d "{\"name\":\"SupportedClientVersions\",\"value\":{\"python\":\"${VERSION}\"}}" publish-eu1: name: Publish to PyPI and update eu1 BE client-versions @@ -216,8 +216,8 @@ jobs: exit 1 fi - curl --fail-with-body -X POST \ - "${BASE_URL}/client-versions" \ + curl --fail-with-body -X PATCH \ + "${BASE_URL}/config-variables/SupportedClientVersions" \ -H "Authorization: Bearer ${POLY_SUPER_ADMIN_USER_KEY}" \ -H "Content-Type: application/json" \ - -d "{\"python\":\"${VERSION}\"}" + -d "{\"name\":\"SupportedClientVersions\",\"value\":{\"python\":\"${VERSION}\"}}" diff --git a/polyapi/version_check.py b/polyapi/version_check.py index 7b9460a..4773f93 100644 --- a/polyapi/version_check.py +++ b/polyapi/version_check.py @@ -36,8 +36,6 @@ def _normalize_version(version: str | None) -> Version | None: except InvalidVersion: pass - # best-effort coercion similar to semver.coerce: - # pull first numeric dotted segment with optional pre-release/dev suffix match = re.search(r"(\d+(?:\.\d+){0,2}(?:[a-zA-Z]+\d*)?)", candidate) if not match: return None @@ -81,17 +79,25 @@ def _resolve_instance_tag(base_url: str | None) -> str | None: def _get_client_versions(base_url: str) -> dict: - url = f"{base_url.rstrip('/')}/client-versions" - resp = http_get(url, timeout=5.0) + url = f"{base_url.rstrip('/')}/config-variables/SupportedClientVersions" + api_key, _ = get_api_key_and_url() + headers = {"Authorization": f"Bearer {api_key}"} if api_key else {} + resp = http_get(url, headers=headers, timeout=5.0) resp.raise_for_status() payload = resp.json() if not isinstance(payload, dict): - raise ValueError("Invalid client-versions payload, expected object.") - return payload + raise ValueError("Invalid SupportedClientVersions config variablepayload.") + value = payload.get("value") + if isinstance(value, dict): + return value + + if isinstance(payload.get("python"), str): + return payload + + raise ValueError("Invalid SupportedClientVersions config variable payload.") def _resolve_target_version(versions_payload: dict) -> str | None: - # Per-instance response shape: { "python": "1.14.5", "typescript": "1.20.0" } if isinstance(versions_payload, dict): value = versions_payload.get("python") if isinstance(value, str) and value.strip(): @@ -152,7 +158,7 @@ def check_for_client_version_update() -> None: try: versions_payload = _get_client_versions(base_url) except Exception as ex: - logger.error("Failed to fetch client versions from backend: %s", ex) + logger.error("Failed to fetch client versions from the service: %s", ex) return available_version = _resolve_target_version(versions_payload) @@ -161,7 +167,7 @@ def check_for_client_version_update() -> None: return using_older_version = normalized_current < normalized_available - using_newer_version = normalized_current > normalized_available # noqa: F841 + using_newer_version = normalized_current > normalized_available if not using_older_version and not using_newer_version: return