# CI Setup — 2026-05-14

Wave 1.5 of the framework-hardening roadmap. Adds a GitHub Actions
workflow that gates every push to `main` and every pull request with
lint + a curated, fast test suite.

## Workflow file

`.github/workflows/ci.yml`

### Triggers

| Event | When |
|---|---|
| `push` | Any commit to `main` |
| `pull_request` | Any PR targeting `main` |

Concurrency is configured with `cancel-in-progress: true` so stale
runs are cancelled when a new commit arrives on the same ref.

## Jobs

### `lint` — Ruff (report-only)

Installs `ruff` and runs `ruff check src/ tests/ --exit-zero
--statistics`. The `--exit-zero` flag means ruff findings **do not
fail the build yet**. As of the Wave 1.1 baseline there are 1846
findings (see `docs/test-status-2026-05-14.md`). Applying a
mass-autofix in a single diff would obscure meaningful review history;
that cleanup is tracked as a future chore.

### `test` — Pytest

Installs the package and all dev dependencies via:

```bash
pip install -e ".[dev]"
```

Then runs two pytest invocations in sequence:

#### 1. Smoke + config tests

```bash
pytest tests/integration/ -m "smoke or config" \
  -v --timeout=30 --timeout-method=signal --no-header
```

Covers `tests/integration/test_mcp_smoke.py` (28 tests, fully mocked
MCP tool inventory) and `tests/integration/test_config_audit.py`
(12 tests, YAML config loading). Both are fast and require no external
services. Expected result: **40 passed**.

#### 2. Unit tests (safe subset)

```bash
pytest tests/unit/ \
  --ignore=tests/unit/test_embeddings.py \
  --ignore=tests/unit/test_capsule_builder_orchestrator.py \
  --ignore=tests/unit/test_fetch_doi_lookups.py \
  --deselect tests/unit/test_capsule_reader_ingest.py::test_ingest_chunks_blocks_jsonl \
  --deselect tests/unit/test_capsule_reader_ingest.py::test_ingest_propagates_resource_ids \
  --deselect tests/unit/test_capsule_reader_ingest.py::test_ingest_finalize_false_skips_finish \
  --deselect tests/unit/test_chunking_dispatch.py::test_chunk_falls_back_when_flag_disabled \
  --deselect tests/unit/test_local_docs_capsule_reader_route.py::test_non_capsule_paths_route_to_files \
  --deselect tests/unit/test_local_docs_capsule_reader_route.py::test_mixed_inputs_route_to_both \
  --deselect tests/unit/test_local_docs_external_annotation.py::test_ipynb_outputs_stripped_before_chunking \
  --deselect tests/unit/test_mcp_multi_kb_passthrough.py::test_generate_report_passes_kb_names_to_rag_request \
  --deselect tests/unit/test_mcp_multi_kb_passthrough.py::test_generate_report_single_kb_names_collapses_to_kb_name \
  --deselect tests/unit/test_provenance_engine_wiring.py::test_mcp_generate_report_wires_provenance_and_message_id \
  --deselect tests/unit/test_zotero_ingest_worker.py::test_worker_dedups_by_doi_and_attaches_notes \
  --deselect tests/unit/test_zotero_ingest_worker.py::test_worker_skips_existing_doi \
  --timeout=15 --timeout-method=signal \
  --no-header --tb=line \
  --cov=src/perspicacite --cov-report=xml --cov-report=term-missing
```

Expected result: **869 passed, 12 deselected** in ~20-30 s.

Coverage XML is uploaded as a GitHub Actions artifact
(`coverage-report`, retained 7 days).

## What is excluded and WHY

Full rationale is in `docs/test-status-2026-05-14.md`. Summary:

### Ignored files (entire files skipped)

| File | Reason |
|---|---|
| `tests/unit/test_embeddings.py` | Loads `SentenceTransformerEmbeddingProvider` 6+ times — 2–4 GB RAM per fixture |
| `tests/unit/test_capsule_builder_orchestrator.py` | Makes real HTTP calls to europepmc / doi.org / acs.org |
| `tests/unit/test_fetch_doi_lookups.py` | Makes real HTTP calls to DOI resolver |

Top-level live/integration files are not even collected by pytest
because they live at `tests/` root (not under `tests/unit/` or
`tests/integration/`) and are not explicitly added to the test
invocations. This covers: `test_mcp_live.py`, `test_anthropic_api.py`,
`test_publisher_api_live.py`, `test_download_real.py`,
`test_download_with_bibtex.py`, `test_mcp_server.py`.

### Deselected tests (12 baseline-known failures)

The 12 deselected tests are the documented failures from
`docs/test-status-2026-05-14.md`:

- **7 chunking-hang tests** — `_chunk_by_tokens` in
  `src/perspicacite/pipeline/chunking.py:74` hangs >60 s on trivial
  input. Real production bug; targeted by Wave 3 reliability work.
- **2 mock-signature-drift tests** — `fake_ingest_files()` doesn't
  accept the `external_metadata` kwarg added to production code.
- **1 mock-wiring-drift test** — `query_stream` not invoked by the
  updated provenance engine wiring.
- **2 Pydantic-fixture tests** — `RAGRequest(provider=MagicMock)`
  fails Pydantic validation; mock needs to return a `str`.

## How to debug CI locally

Activate your venv, then run the exact commands from the workflow:

```bash
source .venv/bin/activate

# Lint (report-only)
ruff check src/ tests/ --exit-zero --statistics

# Smoke + config
pytest tests/integration/ -m "smoke or config" \
  -v --timeout=30 --timeout-method=signal --no-header

# Unit (safe subset)
pytest tests/unit/ \
  --ignore=tests/unit/test_embeddings.py \
  --ignore=tests/unit/test_capsule_builder_orchestrator.py \
  --ignore=tests/unit/test_fetch_doi_lookups.py \
  --deselect tests/unit/test_capsule_reader_ingest.py::test_ingest_chunks_blocks_jsonl \
  --deselect tests/unit/test_capsule_reader_ingest.py::test_ingest_propagates_resource_ids \
  --deselect tests/unit/test_capsule_reader_ingest.py::test_ingest_finalize_false_skips_finish \
  --deselect tests/unit/test_chunking_dispatch.py::test_chunk_falls_back_when_flag_disabled \
  --deselect tests/unit/test_local_docs_capsule_reader_route.py::test_non_capsule_paths_route_to_files \
  --deselect tests/unit/test_local_docs_capsule_reader_route.py::test_mixed_inputs_route_to_both \
  --deselect tests/unit/test_local_docs_external_annotation.py::test_ipynb_outputs_stripped_before_chunking \
  --deselect tests/unit/test_mcp_multi_kb_passthrough.py::test_generate_report_passes_kb_names_to_rag_request \
  --deselect tests/unit/test_mcp_multi_kb_passthrough.py::test_generate_report_single_kb_names_collapses_to_kb_name \
  --deselect tests/unit/test_provenance_engine_wiring.py::test_mcp_generate_report_wires_provenance_and_message_id \
  --deselect tests/unit/test_zotero_ingest_worker.py::test_worker_dedups_by_doi_and_attaches_notes \
  --deselect tests/unit/test_zotero_ingest_worker.py::test_worker_skips_existing_doi \
  --timeout=15 --timeout-method=signal \
  --no-header --tb=line \
  --cov=src/perspicacite --cov-report=xml --cov-report=term-missing \
  --cov-fail-under=0
```

Note: `--cov-fail-under=0` overrides the `fail_under = 70` in `pyproject.toml`
`[tool.coverage.report]`. The safe-subset run covers only ~41% of the codebase
(the skipped tests cover the remaining paths). Enforcing 70% on a partial run
would cause a false CI failure.

**Always use `--timeout-method=signal`**. The default `thread` method
doesn't reliably interrupt async tests and causes the suite to wedge
on the `chunking.py:74` hang.

## Known future cleanup items

| Item | Tracked under |
|---|---|
| Ruff `--exit-zero` → fail build after 1846 findings cleaned up | Future cleanup wave (post Wave 3) |
| Coverage `--cov-fail-under=0` → raise threshold once more code paths are covered by CI tests | Post Wave 3 (the 41% coverage from the safe-subset run is a floor, not a ceiling) |
| Fix 7 chunking-hang tests (`chunking.py:74`) | Wave 3 reliability |
| Fix 5 stale-mock tests (mock-signature, Pydantic, fixture drift) | Wave 3 reliability |
| Formalise `live` / `real` marker as separate gated suite | Wave 6.1 E2E pipeline tests |
| Move HTTP-calling files out of `tests/unit/` | Wave 3.4 error-path audit |

## GitHub Actions run

The first workflow run triggered automatically on the commit that
introduced this file (`feat(ci): GitHub Actions for lint + smoke +
safe unit subset (Wave 1.5)`).

To check run status:

```bash
gh run list --workflow=ci.yml --limit 5
```

To inspect a failed run:

```bash
gh run view <run-id> --log-failed
```
