diff --git a/.github/workflows/ci-gate.yml b/.github/workflows/ci-gate.yml new file mode 100644 index 000000000..97840150c --- /dev/null +++ b/.github/workflows/ci-gate.yml @@ -0,0 +1,171 @@ +name: CI Gate + +# Single required status check for all PRs. +# +# Path-filtered CI workflows can't be marked as required in branch +# protection: on a PR that doesn't touch their paths they never report, and +# the required check hangs at "Expected" forever. This gate solves that. It +# runs on every PR, detects which packages changed, calls only the relevant +# package CI workflows (as reusable workflows), and the final "CI Gate" job +# reports the aggregate result — success when every invoked pipeline passed +# (skipped pipelines are fine), failure when any failed. +# +# Branch protection should require exactly one status check: "CI Gate". +# +# Package CI workflows keep their own push-to-main and workflow_dispatch +# triggers; only their pull_request triggers moved here. To wire in a new +# package: add a filter under the `changes` job, a call job that `uses:` the +# package workflow, and list the call job in the gate's `needs`. + +on: + pull_request: + +concurrency: + group: ci-gate-${{ github.event.pull_request.number }} + cancel-in-progress: true + +permissions: + contents: read + pull-requests: read + +jobs: + changes: + name: Detect changed packages + runs-on: ubuntu-latest + outputs: + python_sdk: ${{ steps.filter.outputs.python_sdk }} + ts_sdk: ${{ steps.filter.outputs.ts_sdk }} + cli_python: ${{ steps.filter.outputs.cli_python }} + cli_node: ${{ steps.filter.outputs.cli_node }} + openclaw: ${{ steps.filter.outputs.openclaw }} + opencode_plugin: ${{ steps.filter.outputs.opencode_plugin }} + pi_agent_plugin: ${{ steps.filter.outputs.pi_agent_plugin }} + docs_llms_txt: ${{ steps.filter.outputs.docs_llms_txt }} + steps: + - uses: dorny/paths-filter@v3 + id: filter + with: + # Each filter mirrors the package workflow's old pull_request + # paths, plus the package workflow file itself and this gate file + # (changing either must re-exercise the pipeline). + filters: | + python_sdk: + - 'mem0/**' + - 'tests/**' + - 'pyproject.toml' + - '.github/workflows/ci.yml' + - '.github/workflows/ci-gate.yml' + ts_sdk: + - 'mem0-ts/**' + - '.github/workflows/ts-sdk-ci.yml' + - '.github/workflows/ci-gate.yml' + cli_python: + - 'cli/python/**' + - '.github/workflows/cli-python-ci.yml' + - '.github/workflows/ci-gate.yml' + cli_node: + - 'cli/node/**' + - '.github/workflows/cli-node-ci.yml' + - '.github/workflows/ci-gate.yml' + openclaw: + - 'openclaw/**' + - '.github/workflows/openclaw-checks.yml' + - '.github/workflows/ci-gate.yml' + opencode_plugin: + - 'mem0-plugin/.opencode-plugin/**' + - '.github/workflows/opencode-plugin-checks.yml' + - '.github/workflows/ci-gate.yml' + pi_agent_plugin: + - 'pi-agent-plugin/**' + - '.github/workflows/pi-agent-plugin-checks.yml' + - '.github/workflows/ci-gate.yml' + docs_llms_txt: + - 'docs/**/*.mdx' + - 'docs/llms.txt' + - 'scripts/check-llms-txt-coverage.py' + - 'scripts/llms-txt-ignore.txt' + - '.github/workflows/docs-llms-txt-check.yml' + - '.github/workflows/ci-gate.yml' + + python-sdk: + name: Python SDK + needs: changes + if: needs.changes.outputs.python_sdk == 'true' + uses: ./.github/workflows/ci.yml + secrets: inherit + + ts-sdk: + name: TypeScript SDK + needs: changes + if: needs.changes.outputs.ts_sdk == 'true' + uses: ./.github/workflows/ts-sdk-ci.yml + secrets: inherit + + cli-python: + name: Python CLI + needs: changes + if: needs.changes.outputs.cli_python == 'true' + uses: ./.github/workflows/cli-python-ci.yml + secrets: inherit + + cli-node: + name: Node CLI + needs: changes + if: needs.changes.outputs.cli_node == 'true' + uses: ./.github/workflows/cli-node-ci.yml + secrets: inherit + + openclaw: + name: OpenClaw + needs: changes + if: needs.changes.outputs.openclaw == 'true' + uses: ./.github/workflows/openclaw-checks.yml + secrets: inherit + + opencode-plugin: + name: OpenCode Plugin + needs: changes + if: needs.changes.outputs.opencode_plugin == 'true' + uses: ./.github/workflows/opencode-plugin-checks.yml + secrets: inherit + + pi-agent-plugin: + name: Pi Agent Plugin + needs: changes + if: needs.changes.outputs.pi_agent_plugin == 'true' + uses: ./.github/workflows/pi-agent-plugin-checks.yml + secrets: inherit + + docs-llms-txt: + name: docs llms.txt + needs: changes + if: needs.changes.outputs.docs_llms_txt == 'true' + uses: ./.github/workflows/docs-llms-txt-check.yml + secrets: inherit + + gate: + name: CI Gate + needs: + - changes + - python-sdk + - ts-sdk + - cli-python + - cli-node + - openclaw + - opencode-plugin + - pi-agent-plugin + - docs-llms-txt + if: always() + runs-on: ubuntu-latest + steps: + - name: Evaluate pipeline results + env: + NEEDS: ${{ toJSON(needs) }} + run: | + echo "$NEEDS" | jq -r 'to_entries[] | "\(.key): \(.value.result)"' + failed=$(echo "$NEEDS" | jq -r '[to_entries[] | select(.value.result == "failure" or .value.result == "cancelled") | .key] | join(", ")') + if [ -n "$failed" ]; then + echo "::error::Failing pipelines: $failed" + exit 1 + fi + echo "All pipelines relevant to this change passed." diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a470bfca9..e28983cff 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,9 +1,11 @@ name: ci +# On PRs this is invoked by ci-gate.yml (the single required check); +# push-to-main runs remain standalone. on: push: branches: [main] - pull_request: + workflow_call: jobs: changelog_check: diff --git a/.github/workflows/cli-node-ci.yml b/.github/workflows/cli-node-ci.yml index c4ebb4dc8..b1a2a0d38 100644 --- a/.github/workflows/cli-node-ci.yml +++ b/.github/workflows/cli-node-ci.yml @@ -1,5 +1,7 @@ name: CLI Node CI +# On PRs this is invoked by ci-gate.yml (the single required check); +# push-to-main and manual runs remain standalone. on: workflow_dispatch: push: @@ -7,10 +9,7 @@ on: paths: - 'cli/node/**' - '.github/workflows/cli-node-ci.yml' - pull_request: - paths: - - 'cli/node/**' - - '.github/workflows/cli-node-ci.yml' + workflow_call: jobs: lint: diff --git a/.github/workflows/cli-python-ci.yml b/.github/workflows/cli-python-ci.yml index 1fbe6bf0e..26d4a16fb 100644 --- a/.github/workflows/cli-python-ci.yml +++ b/.github/workflows/cli-python-ci.yml @@ -1,5 +1,7 @@ name: CLI Python CI +# On PRs this is invoked by ci-gate.yml (the single required check); +# push-to-main and manual runs remain standalone. on: workflow_dispatch: push: @@ -7,10 +9,7 @@ on: paths: - 'cli/python/**' - '.github/workflows/cli-python-ci.yml' - pull_request: - paths: - - 'cli/python/**' - - '.github/workflows/cli-python-ci.yml' + workflow_call: jobs: lint: diff --git a/.github/workflows/docs-llms-txt-check.yml b/.github/workflows/docs-llms-txt-check.yml index adbac679a..019eae884 100644 --- a/.github/workflows/docs-llms-txt-check.yml +++ b/.github/workflows/docs-llms-txt-check.yml @@ -6,13 +6,10 @@ name: docs - llms.txt check # python scripts/check-llms-txt-coverage.py # read-only # python scripts/check-llms-txt-coverage.py --write # scaffold placeholders +# On PRs this is invoked by ci-gate.yml (the single required check); +# manual runs remain standalone. on: - pull_request: - paths: - - 'docs/**/*.mdx' - - 'docs/llms.txt' - - 'scripts/check-llms-txt-coverage.py' - - 'scripts/llms-txt-ignore.txt' + workflow_call: workflow_dispatch: {} permissions: diff --git a/.github/workflows/openclaw-checks.yml b/.github/workflows/openclaw-checks.yml index 114f54ed6..611ac0a0a 100644 --- a/.github/workflows/openclaw-checks.yml +++ b/.github/workflows/openclaw-checks.yml @@ -1,5 +1,7 @@ name: openclaw checks +# On PRs this is invoked by ci-gate.yml (the single required check); +# push-to-main and manual runs remain standalone. on: workflow_dispatch: push: @@ -7,10 +9,7 @@ on: paths: - 'openclaw/**' - '.github/workflows/openclaw-checks.yml' - pull_request: - paths: - - 'openclaw/**' - - '.github/workflows/openclaw-checks.yml' + workflow_call: jobs: lint: diff --git a/.github/workflows/opencode-plugin-checks.yml b/.github/workflows/opencode-plugin-checks.yml index 032e8817a..514a20166 100644 --- a/.github/workflows/opencode-plugin-checks.yml +++ b/.github/workflows/opencode-plugin-checks.yml @@ -1,5 +1,7 @@ name: opencode-plugin checks +# On PRs this is invoked by ci-gate.yml (the single required check); +# push-to-main and manual runs remain standalone. on: workflow_dispatch: push: @@ -7,10 +9,7 @@ on: paths: - 'mem0-plugin/.opencode-plugin/**' - '.github/workflows/opencode-plugin-checks.yml' - pull_request: - paths: - - 'mem0-plugin/.opencode-plugin/**' - - '.github/workflows/opencode-plugin-checks.yml' + workflow_call: jobs: build: diff --git a/.github/workflows/pi-agent-plugin-checks.yml b/.github/workflows/pi-agent-plugin-checks.yml index 71118b72e..ba18c07bc 100644 --- a/.github/workflows/pi-agent-plugin-checks.yml +++ b/.github/workflows/pi-agent-plugin-checks.yml @@ -1,5 +1,7 @@ name: pi-agent-plugin checks +# On PRs this is invoked by ci-gate.yml (the single required check); +# push-to-main and manual runs remain standalone. on: workflow_dispatch: push: @@ -7,10 +9,7 @@ on: paths: - 'pi-agent-plugin/**' - '.github/workflows/pi-agent-plugin-checks.yml' - pull_request: - paths: - - 'pi-agent-plugin/**' - - '.github/workflows/pi-agent-plugin-checks.yml' + workflow_call: jobs: lint: diff --git a/.github/workflows/ts-sdk-ci.yml b/.github/workflows/ts-sdk-ci.yml index 8edfdecd8..6dd38b257 100644 --- a/.github/workflows/ts-sdk-ci.yml +++ b/.github/workflows/ts-sdk-ci.yml @@ -1,14 +1,14 @@ name: TypeScript SDK CI +# On PRs this is invoked by ci-gate.yml (the single required check); +# push-to-main runs remain standalone. on: push: branches: [main] paths: - 'mem0-ts/**' - '.github/workflows/ts-sdk-ci.yml' - pull_request: - paths: - - 'mem0-ts/**' + workflow_call: jobs: check_changes: diff --git a/AGENTS.md b/AGENTS.md index 40efa7e41..32f07f97d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -406,15 +406,21 @@ To add a new LLM, embedding, vector store, or reranker provider: ### CI Workflows (automated testing) -| Workflow | File | Triggers | Tests | -|----------|------|----------|-------| -| Python SDK | `ci.yml` | Push to main, PRs on `mem0/`, `tests/`, `pyproject.toml` | Ruff lint + pytest on Python 3.10, 3.11, 3.12 | -| TypeScript SDK | `ts-sdk-ci.yml` | Push to main, PRs on `mem0-ts/` | Prettier + build + jest on Node 20, 22 | -| Python CLI | `cli-python-ci.yml` | Push to `cli/python/`, PRs, manual | Ruff lint + pytest + hatch build on Python 3.10, 3.11, 3.12 | -| Node CLI | `cli-node-ci.yml` | Push to `cli/node/`, PRs, manual | Biome lint + tsc + vitest + tsup build on Node 20, 22 | -| OpenClaw | `openclaw-checks.yml` | Push to `openclaw/`, PRs, manual | tsc + vitest (with Codecov) + tsup build on Node 20, 22 | -| OpenCode Plugin | `opencode-plugin-checks.yml` | Push to `mem0-plugin/.opencode-plugin/`, PRs, manual | Bun: tsc type-check + build + dist artifact check | -| Pi Agent Plugin | `pi-agent-plugin-checks.yml` | Push to `pi-agent-plugin/`, PRs, manual | tsc + vitest + tsup build (dist artifact check) on Node 20, 22 | +PR testing is orchestrated by a single entry point: **`ci-gate.yml` (CI Gate)** runs on every PR, detects which packages changed, and invokes only the relevant package workflows below as reusable workflows (`workflow_call`). Its final **`CI Gate`** job aggregates the results (skipped pipelines pass; failed or cancelled ones fail) and is the **only status check that needs to be required** in branch protection. Package workflows keep their own push-to-main and manual triggers; their `pull_request` triggers moved into the gate's path filters. + +| Workflow | File | Standalone Triggers | Tests | +|----------|------|---------------------|-------| +| CI Gate | `ci-gate.yml` | All PRs | Routes to and aggregates the workflows below | +| Python SDK | `ci.yml` | Push to main | Ruff lint + pytest on Python 3.10, 3.11, 3.12 | +| TypeScript SDK | `ts-sdk-ci.yml` | Push to main (on `mem0-ts/`) | Prettier + build + jest on Node 20, 22 | +| Python CLI | `cli-python-ci.yml` | Push to main (on `cli/python/`), manual | Ruff lint + pytest + hatch build on Python 3.10, 3.11, 3.12 | +| Node CLI | `cli-node-ci.yml` | Push to main (on `cli/node/`), manual | Biome lint + tsc + vitest + tsup build on Node 20, 22 | +| OpenClaw | `openclaw-checks.yml` | Push to main (on `openclaw/`), manual | tsc + vitest (with Codecov) + tsup build on Node 20, 22 | +| OpenCode Plugin | `opencode-plugin-checks.yml` | Push to main (on `mem0-plugin/.opencode-plugin/`), manual | Bun: tsc type-check + build + dist artifact check | +| Pi Agent Plugin | `pi-agent-plugin-checks.yml` | Push to main (on `pi-agent-plugin/`), manual | tsc + vitest + tsup build (dist artifact check) on Node 20, 22 | +| docs llms.txt | `docs-llms-txt-check.yml` | Manual | `docs/llms.txt` coverage check | + +When adding a new package CI workflow: give it `workflow_call` (plus `push`/`workflow_dispatch` as needed, but no `pull_request` trigger), then register it in `ci-gate.yml` — a path filter under the `changes` job, a call job, and an entry in the gate job's `needs` list. ### CD Workflows (automated publishing) diff --git a/cli/node/vitest.config.ts b/cli/node/vitest.config.ts index 36cc7df04..89074962f 100644 --- a/cli/node/vitest.config.ts +++ b/cli/node/vitest.config.ts @@ -8,4 +8,10 @@ export default defineConfig({ define: { __CLI_VERSION__: JSON.stringify(pkg.version), }, + test: { + // Integration tests spawn the CLI via `npx tsx` (15s subprocess + // timeout); the first spawn in a file pays a cold-start cost that can + // exceed vitest's 5s default on CI runners. + testTimeout: 30_000, + }, }); diff --git a/cli/python/tests/test_commands.py b/cli/python/tests/test_commands.py index 4f8036623..4fd6a0c39 100644 --- a/cli/python/tests/test_commands.py +++ b/cli/python/tests/test_commands.py @@ -8,8 +8,8 @@ from io import StringIO from unittest.mock import patch import pytest -from click.exceptions import Exit as ClickExit from rich.console import Console +from typer import Exit as TyperExit from mem0_cli.commands.config_cmd import ( cmd_config_get, @@ -181,7 +181,7 @@ class TestAddCommand: patch("mem0_cli.commands.memory.console", console), patch("mem0_cli.commands.memory.err_console", err_console), patch("mem0_cli.commands.memory._stdin_is_piped", return_value=False), - pytest.raises((SystemExit, ClickExit)), + pytest.raises((SystemExit, TyperExit)), ): cmd_add( mock_backend, @@ -206,7 +206,7 @@ class TestAddCommand: with ( patch("mem0_cli.commands.memory.console", console), patch("mem0_cli.commands.memory.err_console", err_console), - pytest.raises((SystemExit, ClickExit)), + pytest.raises((SystemExit, TyperExit)), ): cmd_add( mock_backend, @@ -764,7 +764,7 @@ class TestImportCommand: with ( patch("mem0_cli.commands.utils.console", console), patch("mem0_cli.commands.utils.err_console", err_console), - pytest.raises((SystemExit, ClickExit)), + pytest.raises((SystemExit, TyperExit)), ): cmd_import(mock_backend, "/nonexistent/file.json", user_id=None, agent_id=None) @@ -801,7 +801,7 @@ class TestEntitiesListCommand: with ( patch("mem0_cli.commands.entities.console", console), patch("mem0_cli.commands.entities.err_console", err_console), - pytest.raises((SystemExit, ClickExit)), + pytest.raises((SystemExit, TyperExit)), ): cmd_entities_list(mock_backend, "invalid", output="table") @@ -944,7 +944,7 @@ class TestEntitiesDeleteCommand: with ( patch("mem0_cli.commands.entities.console", console), patch("mem0_cli.commands.entities.err_console", err_console), - pytest.raises((SystemExit, ClickExit)), + pytest.raises((SystemExit, TyperExit)), ): cmd_entities_delete( mock_backend, @@ -1308,7 +1308,7 @@ class TestAgentMode: patch("mem0_cli.commands.memory.console", console), patch("mem0_cli.commands.memory.err_console", err_console), patch("sys.stdout", captured_stdout), - pytest.raises((SystemExit, ClickExit)), + pytest.raises((SystemExit, TyperExit)), ): cmd_get(mock_backend, "bad-id", output="text")