ci: aggregate all PR testing behind a single required CI Gate workflow (#5476)

This commit is contained in:
Saket Aryan
2026-06-11 15:24:51 +05:30
committed by GitHub
parent 8399b088a5
commit 9226ee2229
12 changed files with 223 additions and 46 deletions
+171
View File
@@ -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."
+3 -1
View File
@@ -1,9 +1,11 @@
name: ci name: ci
# On PRs this is invoked by ci-gate.yml (the single required check);
# push-to-main runs remain standalone.
on: on:
push: push:
branches: [main] branches: [main]
pull_request: workflow_call:
jobs: jobs:
changelog_check: changelog_check:
+3 -4
View File
@@ -1,5 +1,7 @@
name: CLI Node CI 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: on:
workflow_dispatch: workflow_dispatch:
push: push:
@@ -7,10 +9,7 @@ on:
paths: paths:
- 'cli/node/**' - 'cli/node/**'
- '.github/workflows/cli-node-ci.yml' - '.github/workflows/cli-node-ci.yml'
pull_request: workflow_call:
paths:
- 'cli/node/**'
- '.github/workflows/cli-node-ci.yml'
jobs: jobs:
lint: lint:
+3 -4
View File
@@ -1,5 +1,7 @@
name: CLI Python CI 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: on:
workflow_dispatch: workflow_dispatch:
push: push:
@@ -7,10 +9,7 @@ on:
paths: paths:
- 'cli/python/**' - 'cli/python/**'
- '.github/workflows/cli-python-ci.yml' - '.github/workflows/cli-python-ci.yml'
pull_request: workflow_call:
paths:
- 'cli/python/**'
- '.github/workflows/cli-python-ci.yml'
jobs: jobs:
lint: lint:
+3 -6
View File
@@ -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 # read-only
# python scripts/check-llms-txt-coverage.py --write # scaffold placeholders # 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: on:
pull_request: workflow_call:
paths:
- 'docs/**/*.mdx'
- 'docs/llms.txt'
- 'scripts/check-llms-txt-coverage.py'
- 'scripts/llms-txt-ignore.txt'
workflow_dispatch: {} workflow_dispatch: {}
permissions: permissions:
+3 -4
View File
@@ -1,5 +1,7 @@
name: openclaw checks 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: on:
workflow_dispatch: workflow_dispatch:
push: push:
@@ -7,10 +9,7 @@ on:
paths: paths:
- 'openclaw/**' - 'openclaw/**'
- '.github/workflows/openclaw-checks.yml' - '.github/workflows/openclaw-checks.yml'
pull_request: workflow_call:
paths:
- 'openclaw/**'
- '.github/workflows/openclaw-checks.yml'
jobs: jobs:
lint: lint:
+3 -4
View File
@@ -1,5 +1,7 @@
name: opencode-plugin checks 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: on:
workflow_dispatch: workflow_dispatch:
push: push:
@@ -7,10 +9,7 @@ on:
paths: paths:
- 'mem0-plugin/.opencode-plugin/**' - 'mem0-plugin/.opencode-plugin/**'
- '.github/workflows/opencode-plugin-checks.yml' - '.github/workflows/opencode-plugin-checks.yml'
pull_request: workflow_call:
paths:
- 'mem0-plugin/.opencode-plugin/**'
- '.github/workflows/opencode-plugin-checks.yml'
jobs: jobs:
build: build:
+3 -4
View File
@@ -1,5 +1,7 @@
name: pi-agent-plugin checks 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: on:
workflow_dispatch: workflow_dispatch:
push: push:
@@ -7,10 +9,7 @@ on:
paths: paths:
- 'pi-agent-plugin/**' - 'pi-agent-plugin/**'
- '.github/workflows/pi-agent-plugin-checks.yml' - '.github/workflows/pi-agent-plugin-checks.yml'
pull_request: workflow_call:
paths:
- 'pi-agent-plugin/**'
- '.github/workflows/pi-agent-plugin-checks.yml'
jobs: jobs:
lint: lint:
+3 -3
View File
@@ -1,14 +1,14 @@
name: TypeScript SDK CI name: TypeScript SDK CI
# On PRs this is invoked by ci-gate.yml (the single required check);
# push-to-main runs remain standalone.
on: on:
push: push:
branches: [main] branches: [main]
paths: paths:
- 'mem0-ts/**' - 'mem0-ts/**'
- '.github/workflows/ts-sdk-ci.yml' - '.github/workflows/ts-sdk-ci.yml'
pull_request: workflow_call:
paths:
- 'mem0-ts/**'
jobs: jobs:
check_changes: check_changes:
+15 -9
View File
@@ -406,15 +406,21 @@ To add a new LLM, embedding, vector store, or reranker provider:
### CI Workflows (automated testing) ### CI Workflows (automated testing)
| Workflow | File | Triggers | Tests | 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.
|----------|------|----------|-------|
| Python SDK | `ci.yml` | Push to main, PRs on `mem0/`, `tests/`, `pyproject.toml` | Ruff lint + pytest on Python 3.10, 3.11, 3.12 | | Workflow | File | Standalone Triggers | Tests |
| 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 | | CI Gate | `ci-gate.yml` | All PRs | Routes to and aggregates the workflows below |
| Node CLI | `cli-node-ci.yml` | Push to `cli/node/`, PRs, manual | Biome lint + tsc + vitest + tsup build on Node 20, 22 | | Python SDK | `ci.yml` | Push to main | Ruff lint + pytest on Python 3.10, 3.11, 3.12 |
| OpenClaw | `openclaw-checks.yml` | Push to `openclaw/`, PRs, manual | tsc + vitest (with Codecov) + tsup build on Node 20, 22 | | TypeScript SDK | `ts-sdk-ci.yml` | Push to main (on `mem0-ts/`) | Prettier + build + jest 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 | | 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 |
| 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 | | 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) ### CD Workflows (automated publishing)
+6
View File
@@ -8,4 +8,10 @@ export default defineConfig({
define: { define: {
__CLI_VERSION__: JSON.stringify(pkg.version), __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,
},
}); });
+7 -7
View File
@@ -8,8 +8,8 @@ from io import StringIO
from unittest.mock import patch from unittest.mock import patch
import pytest import pytest
from click.exceptions import Exit as ClickExit
from rich.console import Console from rich.console import Console
from typer import Exit as TyperExit
from mem0_cli.commands.config_cmd import ( from mem0_cli.commands.config_cmd import (
cmd_config_get, cmd_config_get,
@@ -181,7 +181,7 @@ class TestAddCommand:
patch("mem0_cli.commands.memory.console", console), patch("mem0_cli.commands.memory.console", console),
patch("mem0_cli.commands.memory.err_console", err_console), patch("mem0_cli.commands.memory.err_console", err_console),
patch("mem0_cli.commands.memory._stdin_is_piped", return_value=False), patch("mem0_cli.commands.memory._stdin_is_piped", return_value=False),
pytest.raises((SystemExit, ClickExit)), pytest.raises((SystemExit, TyperExit)),
): ):
cmd_add( cmd_add(
mock_backend, mock_backend,
@@ -206,7 +206,7 @@ class TestAddCommand:
with ( with (
patch("mem0_cli.commands.memory.console", console), patch("mem0_cli.commands.memory.console", console),
patch("mem0_cli.commands.memory.err_console", err_console), patch("mem0_cli.commands.memory.err_console", err_console),
pytest.raises((SystemExit, ClickExit)), pytest.raises((SystemExit, TyperExit)),
): ):
cmd_add( cmd_add(
mock_backend, mock_backend,
@@ -764,7 +764,7 @@ class TestImportCommand:
with ( with (
patch("mem0_cli.commands.utils.console", console), patch("mem0_cli.commands.utils.console", console),
patch("mem0_cli.commands.utils.err_console", err_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) cmd_import(mock_backend, "/nonexistent/file.json", user_id=None, agent_id=None)
@@ -801,7 +801,7 @@ class TestEntitiesListCommand:
with ( with (
patch("mem0_cli.commands.entities.console", console), patch("mem0_cli.commands.entities.console", console),
patch("mem0_cli.commands.entities.err_console", err_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") cmd_entities_list(mock_backend, "invalid", output="table")
@@ -944,7 +944,7 @@ class TestEntitiesDeleteCommand:
with ( with (
patch("mem0_cli.commands.entities.console", console), patch("mem0_cli.commands.entities.console", console),
patch("mem0_cli.commands.entities.err_console", err_console), patch("mem0_cli.commands.entities.err_console", err_console),
pytest.raises((SystemExit, ClickExit)), pytest.raises((SystemExit, TyperExit)),
): ):
cmd_entities_delete( cmd_entities_delete(
mock_backend, mock_backend,
@@ -1308,7 +1308,7 @@ class TestAgentMode:
patch("mem0_cli.commands.memory.console", console), patch("mem0_cli.commands.memory.console", console),
patch("mem0_cli.commands.memory.err_console", err_console), patch("mem0_cli.commands.memory.err_console", err_console),
patch("sys.stdout", captured_stdout), patch("sys.stdout", captured_stdout),
pytest.raises((SystemExit, ClickExit)), pytest.raises((SystemExit, TyperExit)),
): ):
cmd_get(mock_backend, "bad-id", output="text") cmd_get(mock_backend, "bad-id", output="text")