From 34b4ac04fc0e780780e7ad2e3ee6dc336341cdca Mon Sep 17 00:00:00 2001 From: iliassjabali Date: Sun, 12 Apr 2026 00:32:42 +0100 Subject: [PATCH 1/5] ci: add Python tests with paths-filter and pip cache Adds python-changes detector + python-tests matrix job to ci.yml so the 282 Python tests across control-plane, operator, sdk-langgraph, and sdk-python run on every PR that touches them and on every push to main. - dorny/paths-filter scopes runs to changed packages on PRs - push: main always runs all 4 packages as a safety net - actions/setup-python pip cache keyed per package - ci-success gate depends on both jobs and treats skipped as ok Closes #22 --- .github/workflows/ci.yml | 103 +++++++++++++++++++++++++++++++++------ 1 file changed, 87 insertions(+), 16 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b67da38..83e58ed 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -101,6 +101,79 @@ jobs: - run: pnpm test + # ── Detect which Python packages changed ──────────────────────────────────── + python-changes: + name: Detect Python changes + runs-on: ubuntu-latest + timeout-minutes: 2 + outputs: + packages: ${{ steps.set.outputs.packages }} + steps: + - uses: actions/checkout@v4 + + - uses: dorny/paths-filter@v3 + id: filter + with: + filters: | + control-plane: + - 'packages/control-plane/**' + - '.github/workflows/ci.yml' + operator: + - 'packages/operator/**' + - '.github/workflows/ci.yml' + sdk-langgraph: + - 'packages/sdk-langgraph/**' + - '.github/workflows/ci.yml' + sdk-python: + - 'packages/sdk-python/**' + - '.github/workflows/ci.yml' + + - id: set + run: | + if [[ "${{ github.event_name }}" == "push" ]]; then + echo 'packages=["control-plane","operator","sdk-langgraph","sdk-python"]' >> "$GITHUB_OUTPUT" + else + echo "packages=${{ steps.filter.outputs.changes }}" >> "$GITHUB_OUTPUT" + fi + + # ── Python Tests ──────────────────────────────────────────────────────────── + python-tests: + name: Python Tests (${{ matrix.package }}) + needs: python-changes + if: needs.python-changes.outputs.packages != '[]' && needs.python-changes.outputs.packages != '' + runs-on: ubuntu-latest + timeout-minutes: 10 + + strategy: + fail-fast: false + matrix: + package: ${{ fromJSON(needs.python-changes.outputs.packages) }} + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + cache: pip + cache-dependency-path: | + packages/${{ matrix.package }}/requirements-dev.txt + packages/${{ matrix.package }}/pyproject.toml + + - name: Install dependencies + working-directory: packages/${{ matrix.package }} + run: | + python -m pip install --upgrade pip + if [[ -f requirements-dev.txt ]]; then + pip install -r requirements-dev.txt + else + pip install -e .[dev] + fi + + - name: Run pytest + working-directory: packages/${{ matrix.package }} + run: pytest -v + # ── Sidecar E2E (Docker Compose) ───────────────────────────────────────────── sidecar-e2e: name: Sidecar E2E @@ -163,25 +236,23 @@ jobs: ci-success: name: CI runs-on: ubuntu-latest - needs: [lint, build, test, docker-validate, sidecar-e2e] + needs: [lint, build, test, python-changes, python-tests, docker-validate, sidecar-e2e] if: always() steps: - name: Check all jobs passed run: | - if [[ "${{ needs.lint.result }}" != "success" ]]; then - echo "Lint job failed" && exit 1 - fi - if [[ "${{ needs.build.result }}" != "success" ]]; then - echo "Build job failed" && exit 1 - fi - if [[ "${{ needs.test.result }}" != "success" ]]; then - echo "Test job failed" && exit 1 - fi - if [[ "${{ needs.docker-validate.result }}" != "success" ]]; then - echo "Docker validate job failed" && exit 1 - fi - if [[ "${{ needs.sidecar-e2e.result }}" != "success" ]]; then - echo "Sidecar E2E job failed" && exit 1 - fi + fail_if_not_ok() { + local name="$1" result="$2" + if [[ "$result" != "success" && "$result" != "skipped" ]]; then + echo "$name failed (result=$result)" && exit 1 + fi + } + fail_if_not_ok "Lint" "${{ needs.lint.result }}" + fail_if_not_ok "Build" "${{ needs.build.result }}" + fail_if_not_ok "Test" "${{ needs.test.result }}" + fail_if_not_ok "Python changes" "${{ needs.python-changes.result }}" + fail_if_not_ok "Python tests" "${{ needs.python-tests.result }}" + fail_if_not_ok "Docker validate" "${{ needs.docker-validate.result }}" + fail_if_not_ok "Sidecar E2E" "${{ needs.sidecar-e2e.result }}" echo "All CI jobs passed." From 1bb94c95d8c5d451f4403d657fadaf8d5e5ad5ea Mon Sep 17 00:00:00 2001 From: iliassjabali Date: Sun, 12 Apr 2026 00:36:45 +0100 Subject: [PATCH 2/5] ci(python-tests): pass paths-filter output via env to avoid shell quoting bug The previous version inlined ${{ steps.filter.outputs.changes }} directly inside a double-quoted echo, so the JSON array's double quotes broke shell parsing and the python-tests matrix failed to materialize. Pass the value through an env var instead. --- .github/workflows/ci.yml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 83e58ed..574b9e2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -129,11 +129,14 @@ jobs: - '.github/workflows/ci.yml' - id: set + env: + EVENT_NAME: ${{ github.event_name }} + FILTER_CHANGES: ${{ steps.filter.outputs.changes }} run: | - if [[ "${{ github.event_name }}" == "push" ]]; then + if [[ "$EVENT_NAME" == "push" ]]; then echo 'packages=["control-plane","operator","sdk-langgraph","sdk-python"]' >> "$GITHUB_OUTPUT" else - echo "packages=${{ steps.filter.outputs.changes }}" >> "$GITHUB_OUTPUT" + echo "packages=$FILTER_CHANGES" >> "$GITHUB_OUTPUT" fi # ── Python Tests ──────────────────────────────────────────────────────────── From 4370abea47abfecdbed66e314dc7e354f3bec863 Mon Sep 17 00:00:00 2001 From: iliassjabali Date: Sun, 12 Apr 2026 00:38:16 +0100 Subject: [PATCH 3/5] ci(python-tests): install sdk-langgraph with [dev,opa] extras for httpx test_sidecar_client.py imports the real httpx (not mocked), which lives in the 'opa' extra. Switch to a per-package case so each package gets exactly the deps its tests need. --- .github/workflows/ci.yml | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 574b9e2..6e14ece 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -165,13 +165,22 @@ jobs: - name: Install dependencies working-directory: packages/${{ matrix.package }} + env: + PACKAGE: ${{ matrix.package }} run: | python -m pip install --upgrade pip - if [[ -f requirements-dev.txt ]]; then - pip install -r requirements-dev.txt - else - pip install -e .[dev] - fi + case "$PACKAGE" in + control-plane|operator) + pip install -r requirements-dev.txt + ;; + sdk-langgraph) + # tests/test_sidecar_client.py needs httpx (opa extra) + pip install -e .[dev,opa] + ;; + sdk-python) + pip install -e .[dev] + ;; + esac - name: Run pytest working-directory: packages/${{ matrix.package }} From b2521c1b1631fbddb82ac3f302bc717ee221abc9 Mon Sep 17 00:00:00 2001 From: iliassjabali Date: Sun, 12 Apr 2026 00:43:52 +0100 Subject: [PATCH 4/5] fix(control-plane): rate limiter use None sentinel for first-seen agent The rate-limit window used 0.0 as the 'never seen' sentinel, so on a host where time.monotonic() is small (freshly-booted CI runner, or under a monkeypatched RATE_LIMIT_SECONDS=1000 in tests), the first heartbeat for a brand-new agent could be incorrectly rate-limited. Switch to a None sentinel so the absence of a prior timestamp always allows the request. Discovered when wiring tests/test_heartbeat.py::test_heartbeat_rate_limit_returns_429 into CI (issue #22). --- packages/control-plane/api/heartbeat.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/control-plane/api/heartbeat.py b/packages/control-plane/api/heartbeat.py index fd50449..57fa510 100644 --- a/packages/control-plane/api/heartbeat.py +++ b/packages/control-plane/api/heartbeat.py @@ -47,8 +47,12 @@ def _check_rate_limit(agent_id: str) -> None: now = time.monotonic() - last = _rate_window.get(agent_id, 0.0) - if now - last < RATE_LIMIT_SECONDS: + last = _rate_window.get(agent_id) + # `last is None` means we have never seen this agent, so always allow. + # Using a numeric sentinel like 0.0 is unsafe: time.monotonic() returns + # seconds since an unspecified point that can be small on freshly-booted + # hosts (e.g. CI runners), causing the first heartbeat to be rate-limited. + if last is not None and now - last < RATE_LIMIT_SECONDS: logger.warning("Rate limit hit for agent %s", agent_id) raise HTTPException( status_code=429, From bf80b0532a8011f102186a15ed3439372450022b Mon Sep 17 00:00:00 2001 From: iliassjabali Date: Sun, 12 Apr 2026 00:52:58 +0100 Subject: [PATCH 5/5] ci: opt into Node 24 runtime for JS actions Sets FORCE_JAVASCRIPT_ACTIONS_TO_NODE24=true at the workflow level so the 13 'Node.js 20 actions are deprecated' warnings disappear ahead of the June 2026 default switch, without bumping any action versions. https://github.blog/changelog/2025-09-19-deprecation-of-node-20-on-github-actions-runners/ --- .github/workflows/ci.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6e14ece..ee3b869 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,6 +11,13 @@ on: permissions: contents: read +# Opt JavaScript actions into Node.js 24 ahead of the June 2026 default switch. +# Suppresses the Node 20 deprecation warning across every action in this workflow +# without forcing version bumps on actions that have not yet shipped a v5 release. +# See https://github.blog/changelog/2025-09-19-deprecation-of-node-20-on-github-actions-runners/ +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: 'true' + # Cancel in-progress runs for the same branch/PR when a new push arrives concurrency: group: ${{ github.workflow }}-${{ github.ref }}