Skip to content

ci: add quality gate (lint, typecheck, test, docker build) - #27

Merged
netty-linux merged 4 commits into
mainfrom
chore/ci-quality-gate
Aug 12, 2026
Merged

ci: add quality gate (lint, typecheck, test, docker build)#27
netty-linux merged 4 commits into
mainfrom
chore/ci-quality-gate

Conversation

@netty-linux

Copy link
Copy Markdown
Owner

Summary

This repo had zero CI before this PR — no .github/workflows at all, no lint/type-check config, only pytest.ini's asyncio_mode = auto. That gap is what let ~5,000 lines of dead code accumulate unnoticed (the vision/ removal, prior PR). This PR adds a quality gate: lint + typecheck + test + Docker build, all gating merges into main.

Design, approved before implementation:

  • Rule set mirrors nullain-agent-sdk's own ruff/pyright selection for ecosystem consistency, with two measured adjustments (B008 dropped — 100% FastAPI Depends()/File() idiom, not a bug; RUF001/RUF002 dropped — legitimate pt-BR typographic content) and one addition (BLE001 — this repo already had 105 # noqa: BLE001 markers before the rule existed, formalizing existing intent).
  • pyright in basic mode (not strict — this codebase was never typed, strict would be a separate, larger effort).
  • Test job: Python 3.12, Ubuntu only, no matrix — this app only ever runs as a Linux container in production, unlike a published library.
  • docker-build: builds the real Dockerfile, push: false — the one artifact nothing verified before.
  • No deploy job, by design. Quality gate only; deploy stays manual on Coolify.

Commits (4, staged so the diff is reviewable in order)

  1. chore(lint)pyproject.toml with [tool.ruff]/[tool.pyright], then ruff check --fix (74 auto-fixes) + ruff format (27 files). Zero behavior change — full suite green before and after.
  2. fix(quality) — every ruff finding needing a real decision. Full itemized list in the commit body (two real except: pass bugs fixed with logging + propagation in attachments_store.py, 2x B904 raise...from, S608/S104/S108 reviewed and documented as false-positives, an N818 rename, RUF006 task-reference fix, 17 S110 sites resolved per-category — real fix, justified suppression, or logged-then-suppressed).
  3. fix(types) — pyright findings in basic mode. Includes a real production bug found via typecheck: psycopg_pool is a separate PyPI package from psycopg[binary] and was never actually installed — meaning Neon attachment persistence has been silently falling back to in-memory-only (no durability across restarts) in any deploy with NEON_DATABASE_URL set. Fixed by adding psycopg-pool to requirements.txt and correcting the misleading error message. Verified inside a real built Docker image that psycopg_pool.AsyncConnectionPool now resolves.
  4. ci — the workflow itself, added last now that the tree is actually green.

Test plan

  • ruff check . / ruff format --check . — clean
  • pyright — 0 errors, 0 warnings
  • pytest — 56 passed, throughout every commit in the sequence
  • Real docker build . (Docker Desktop) succeeded; smoke-tested inside the built image that psycopg_pool, web.server, agent.attachments_store all import cleanly
  • .github/workflows/ci.yml YAML validated with yaml.safe_load

🤖 Generated with Claude Code

netty-linux and others added 4 commits August 12, 2026 12:10
Introduces pyproject.toml with [tool.ruff]/[tool.pyright] — this repo had
zero lint/type-check configuration before this commit (only pytest.ini's
asyncio_mode). Rule set mirrors nullain-agent-sdk's own selection
(E,F,W,I,N,UP,B,S,A,C4,SIM,RUF) for ecosystem consistency, with two
measured adjustments:

- B008 dropped: measured 20 hits, 100% FastAPI's own Depends()/File()
  idiom (function-call-in-default-argument IS how FastAPI's DI works) —
  the rule structurally conflicts with the framework here, not a real bug.
- RUF001/RUF002 dropped: measured 4 hits, all legitimate pt-BR UI/docs
  content ("3× superior", "Nullain × ZuckPay", an ℹ glyph), not typos.
- BLE001 added (flake8-blind-except, not in the SDK's set): this repo
  already had 105 `# noqa: BLE001` annotations on broad `except Exception`
  blocks before this rule was ever enabled — the intent was already
  there; turning it on makes it real instead of a dead trail.

tests/* keeps the SDK's own S101/S105/S106 per-file-ignore (assert is
idiomatic in tests).

This commit applies only mechanical changes — `ruff check --fix` (74
auto-fixes: unused imports, import ordering, SIM/UP simplifications,
trailing whitespace, and removal of 31 now-orphaned `noqa` comments that
referenced rules never actually enabled — 9 stale BLE001/RUF013 plus 21
ARG001/ARG002 in tests/test_sandbox_aio.py, a rule never in scope here)
and `ruff format` (27 files reformatted, pure whitespace/wrapping). Zero
behavioral changes — full test suite green before and after (56 passed).

91 remaining findings (S110/E501/E402/B904/S608/S104/N818/etc.) require
human judgment and are fixed in a separate commit, on purpose — this one
stays pure mechanical noise so the behavioral diff is reviewable alone.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
One commit (not split from the mechanical baseline — see note at bottom)
covering every ruff finding that needed a real decision rather than a
blind auto-fix, after the config/auto-fix baseline commit. Review by this
list, not the full diff — the behavior-changing points are itemized below;
everything else is mechanical residue from the same pass.

## Behavior changes (review these)

- agent/attachments_store.py:244 (fetch_attachments_by_ids) — a Neon
  fetch failure was `except Exception: pass`, silently returning only
  what's cached. Now logs at WARNING with (missing, user_id) before
  falling through — same degrade-and-continue behavior, but the failure
  is no longer invisible.
- agent/attachments_store.py:335 (delete_attachments_by_thread) — a Neon
  delete failure was `except Exception: pass`, orphaning attachments with
  no trace. Now logs at ERROR then re-raises. Observable behavior is
  unchanged: the only caller (web/server.py's delete_thread route) already
  wraps this in its own try/except and always returns {"ok": True}
  regardless — re-raising here just makes the failure visible in logs
  instead of doubly swallowed.
- B904 (raise-without-from), 2 sites — agent/attachments_store.py:83
  (psycopg ImportError) and web/server.py:1032 (UnicodeEncodeError on
  Host header) now chain `from exc`, preserving the original traceback
  instead of discarding it.
- agent/attachments_store.py:159 (RUF006) — the fire-and-forget
  `loop.create_task(_persist_async(...))` had no reference held, so the
  event loop could garbage-collect it mid-execution. Now stored in a
  module-level `_persist_tasks` set with a done-callback that discards it
  on completion.
- agent/attachments_store.py:279 (S608) — reviewed, not a real SQL
  injection: the f-string only interpolates `$1, $2, ...` positional
  placeholders and a param count, never `file_ids`/`user_id` content
  (which travel through `params`, properly parameterized). Documented
  with a noqa + comment rather than restructured.
- web/server.py:1882 (S104, HOST=0.0.0.0) — reviewed, not a real
  exposure: the app always runs in a container (Dockerfile CMD), where
  0.0.0.0 is the correct bind address. Documented with noqa + comment.
- agent/sandbox_docker.py:91 (S108, hardcoded "/tmp") — reviewed, not
  insecure temp-file usage: it's a Docker tmpfs mount spec
  (noexec/nosuid/nodev, size-capped). Documented with noqa + comment.
- agent/zuckpay.py:71 (N818) — `ZuckPayNotConnected` renamed to
  `ZuckPayNotConnectedError` (Error suffix on exception class); updated
  at both call sites (agent/zuckpay.py, agent/zuckpay_tools.py).
- 17 S110 (try/except/pass) sites, resolved per the requested categories:
  - attachments_store.py's two (above): real fix (log + propagate/re-raise).
  - agent/redis_client.py:195 (distributed lock release): suppressed with
    justification — the lock still expires via its own `ex=timeout` TTL,
    so a failed delete self-heals.
  - The remaining 14 (agent/external_mcp.py x2, agent/integrations.py x2,
    agent/sandbox_docker.py x2, agent/wavespeed_tools.py x1, web/auth.py
    x3, web/server.py x4): each is a real fallback path (falls through to
    an in-memory/local alternative, or is genuinely best-effort cleanup)
    — added a `logger.debug`/`.warning` call with rationale before
    keeping the suppression, per "no noqa without a comment explaining
    why." Three of these (web/auth.py's two Redis-lockout checks and its
    CSRF-origin resolution) are security-relevant and log at WARNING
    instead of DEBUG for that reason.
  - Also newly found while fixing agent/sandbox_docker.py:180's S110: a
    pre-existing bug where `ls_result` is computed but never parsed into
    `files` — file-generation detection in the Docker sandbox driver is
    dead. Left as-is (out of scope here, flagged in a comment) — filing
    a follow-up issue separately.

## Residual mechanical fixes (same pass, no behavior change)

SIM102/103/108 (collapsed nested ifs, if/else→ternary, if/else→bool
expr), F841 (removed genuinely-unused local vars), B905 (explicit
`strict=False` on zip()), RUF005 (list unpacking instead of concat),
RUF012 (ClassVar annotations on test fixture classes), E402 (moved
misplaced mid-file imports to the top; web/auth.py also had a redundant
`import secrets as _secrets` shadowing the top-level `import secrets`,
removed), E501 (wrapped long lines; agent/wavespeed_tools.py's one
long line is a literal LLM-facing JSON template that can't be reflowed,
handled via a per-file-ignore in pyproject.toml with rationale), and
`contextlib.suppress(...)` swapped in for a few more try/except/pass
blocks whose exception is intentionally discarded (Windows chmod
incompatibility, idempotent WebSocket close).

Verified: full test suite green (56 passed) after every step, `ruff
check .` and `ruff format --check .` both clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Third commit in the CI-quality-gate sequence (config+auto-fix, then
human-judgment lint fixes, now types) — see pyproject.toml's
[tool.pyright] added in the first commit (typeCheckingMode = "basic").

## Real bug found and fixed (not just a type annotation)

- requirements.txt / agent/attachments_store.py:82 — `psycopg_pool` is a
  separate PyPI package (`psycopg-pool`), not bundled in `psycopg[binary]`
  as the code's own ImportError message claimed. It was never actually
  installed, so `_get_pool()`'s import always failed in any deploy —
  meaning Neon attachment persistence has been silently falling back to
  the in-memory-only cache (`_MEMORY`, no durability across restarts/
  replicas) since this path was written, whenever NEON_DATABASE_URL was
  configured. No data was lost mid-session (the memory cache always
  populates first), but nothing survived a restart. Added
  `psycopg-pool>=3.2.0,<4.0.0` to requirements.txt (same range as the
  `psycopg[binary]` pin already there) and fixed the misleading error
  message. Installed locally and confirmed pyright resolves the import.
- agent/sandbox.py:32 — `files: list[dict[str, Any]] = None` had a type
  that didn't match its own default; declared `| None` explicitly (the
  dataclass's `__post_init__` already normalizes None to `[]`, so this
  is purely a declaration fix, no behavior change).
- agent/external_mcp.py:288 — `_load_store_decrypted()`'s ternary
  (`store.get("mcps") if isinstance(...) else {}`) didn't narrow to
  `dict` for pyright even though it's correct at runtime; rewrote with
  an explicit intermediate variable + annotation instead of relying on
  the ternary's inferred type.
- agent/models.py — `ModelOption.to_dict()` was typed
  `dict[str, str | None]`, wider than reality (only `badge` is ever
  `None`; `id`/`label`/`tagline`/`badge_tone` are always populated
  strings). That imprecision was what made
  `ModelOptionResponse(**o.to_dict())` in web/server.py look unsound to
  pyright. Added a `ModelOptionDict` TypedDict mirroring the real
  per-field nullability instead of suppressing the call site.

## Upstream/framework typing gaps (narrow `type: ignore`, each commented)

- agent/redis_client.py (x3), web/auth.py (x1) — redis-py's stubs type
  hash/incr/hgetall calls as `ResponseT` (a union including the sync
  pipeline-builder's own return type), even though these are plain
  awaited/sync client calls that always return the concrete type at
  runtime.
- agent/zuckpay.py:342 — `float(part.get("percentage"))` where the dict
  value can genuinely be `None`; already caught by the surrounding
  `except (TypeError, ValueError)`, so intentional, not a bug.
- agent/external_mcp.py (x2) — `MultiServerMCPClient`'s constructor wants
  its own `Connection` TypedDict; the local `build_*_server_config()`
  helpers return structurally-compatible plain dicts, not nominally
  typed as one.
- web/server.py — slowapi's rate-limit exception handler doesn't match
  FastAPI's general `ExceptionHandler` signature (known upstream
  mismatch); `current_user(websocket)` passes a `WebSocket` where
  `current_user` is typed for `Request` — both share `.session` via
  `SessionMiddleware` at runtime, they just don't share a type hierarchy.

## Other real fix

- web/server.py's `_stream_with_keepalive` was declared `-> Any` despite
  being an async generator (`yield` in its body) — that mismatch was
  what confused pyright into treating its call site as a plain coroutine
  ("CoroutineType is not iterable"). Declared it accurately as
  `AsyncGenerator[str, None]` instead of suppressing the call site.

Verified: full test suite green (56 passed), `ruff check .`/`ruff format
--check .` both clean, `pyright` 0 errors/0 warnings.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ild)

Fourth and final commit in the CI-quality-gate sequence — the workflow
itself, now that the baseline is actually green (prior 3 commits: ruff/
pyright config + mechanical fixes, human-judgment lint fixes, type fixes).

This repo had zero CI before this PR — nothing caught the ~5,000 lines of
dead code (vision/ removal, earlier PR) or would have caught the
psycopg-pool gap (this PR's third commit) before merge. Three jobs, all
gating PRs into main and re-running on push to main:

- static: ruff check + ruff format --check + pyright, ubuntu-only (lint/
  type errors aren't OS-specific, no reason to triple the cost).
- test: pytest, Python 3.12 only, ubuntu-only — this app only ever runs
  as a Linux container in production (Dockerfile: python:3.12-slim-
  bookworm), unlike a published library that has to support whatever its
  consumers run. A cross-platform/version matrix here would add CI time
  without covering any code path that actually ships.
- docker-build: builds the actual Dockerfile with docker/build-push-
  action, push: false. This is the artifact that deploys (Coolify) and
  was, until now, the one thing no automation verified — confirmed
  locally with a real `docker build .` + a smoke-test import
  (psycopg_pool, web.server, agent.attachments_store all resolve inside
  the built image) before writing this workflow.

Deliberately no deploy job — quality gate only, per scope. Deploy stays
manual on Coolify.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@netty-linux
netty-linux merged commit e1a0d7a into main Aug 12, 2026
3 checks passed
@netty-linux
netty-linux deleted the chore/ci-quality-gate branch August 12, 2026 16:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant