---
name: cb-build-test
description: How Circuit Breaker is built, tested, packaged, and kept secret-safe — the make dev/verify/test targets, the PostgreSQL integration test database and its fixtures, the mono Docker image and native deb/rpm/apk/AppImage packages, and the Fernet vault plus air-gap handling for credentials. Use this whenever running or writing tests, setting up or debugging the dev environment, working on Dockerfile.mono or the entrypoint/supervisord config, building or releasing packages, adding an integration that stores credentials, or touching anything involving CB_VAULT_KEY, CB_AIRGAP, or secret material.
---

# Circuit Breaker — Build, Test & Packaging

## Dev environment

```bash
make install     # bootstrap once
make dev         # deps + migrations + backend + frontend + monitor workers
make deps-up     # Postgres / Redis / NATS in Docker, nothing else
make migrate     # alembic
make reset-oobe  # rewind to the first-run wizard
```

`make dev` runs the app **natively** against Dockerized dependencies. The
Makefile calls this "ZERO DOCKER DRIFT" for `make backend` — the point is that
the thing you debug is the thing that ships. Reach for `make deps-native-up`
when you need prod parity, since it uses the same systemd units `install.sh`
installs on a user's box.

Note `make backend` has no reload; `make backend-watch` does. The comment marks
watch mode as post-fix only, because auto-reload masks import-time failures that
a real start would surface.

## Testing

```bash
make test-backend         # tests/integration — provisions the test DB first
make test-frontend        # vitest
make test                 # both
make verify               # the pre-push gate
make verify-composed      # Tier 2: the browser suite, the composed journey and the mono smoke
make verify-composed-mono # Tier 2: build Dockerfile.mono and run the compose smoke CI runs
```

`CB_COMPOSED_QUARANTINED=0` lifts QUAR-001 locally: the suite runs, minus the tests that have live register
rows, which are deselected exactly as CI deselects them. Add `CB_E2E_NO_DESELECT=1` to run those too; it is
local only, and no workflow may set it. In CI, the composed journey will not re-run on the same
inputs after a failure until each failed test is fixed or has a register row
(`scripts/ci/composed_rerun_guard.py`). Local runs are not guarded, since they are how a fix gets made.

Test code lives in four places, and putting a test in the wrong one is how it
silently never runs:

```
apps/backend/tests/           unit + service tests, own pyproject config
tests/integration/            backend-scoped, needs live PostgreSQL
tests/unit/                   repo-root scoped
tests/build/                  repo-policy / governance suites
apps/frontend/src/__tests__/  vitest, *.test.jsx
```

`tests/build/` is a real enforcement suite — tracked-file policy, governance
files, CLI parity, restart probes, version parity. It once collected zero tests
because pytest's default `norecursedirs` contains "build"; `pytest.ini` at the
repo root exists specifically to override that. Read the comments in that file
before changing collection settings, because the failure mode is a suite that
looks green and enforces nothing.

### The integration database

`make test-db` runs `scripts/ensure_test_db.py` against `CB_TEST_DB_URL` and
**never drops the database**. Integration tests run with:

```
CB_ALLOW_DEGRADED_DEPENDENCIES=true    # Redis/NATS optional
CB_ALLOW_DIRECT_EGRESS=true
```

These tests hit real PostgreSQL on purpose. Don't mock the database in
`tests/integration/` — assert against real persisted state, because the bugs
worth catching there are constraint, migration, and transaction bugs that a mock
cannot express. Fixtures and factories are in `apps/backend/tests/conftest.py`
and `factories.py`.

When a test fails, decide explicitly whether the **test** is wrong (fixture
drifted from the schema) or the **code** is wrong (missing field, bad query),
and say which. Batch fixes by category instead of re-running the full suite
after each one — the backend suite is minutes, not seconds.

Coverage is gated at `--cov-fail-under=56`, ratcheted to measured reality.
Raise it only after coverage genuinely clears a higher number; never lower it
to turn a build green.

## Packaging

Two distribution paths, both real:

**Native packages** are the primary install route:
```bash
make build           # tarball + deb + rpm + apk + AppImage + .pkg.tar.zst
make build-release   # toolchain + build
make sign            # GPG-sign artifacts + SHA256SUMS (needs GPG_KEY_ID)
make sbom            # syft
```
Driven by `scripts/build_native_release.py` and `nfpm.yaml` (arch from `GOARCH`).

Publishing a version is not a packaging task: it goes through `release.yml`
(`make release-candidate`, one approval, the promote creates the tag). Never
push a `v*` tag by hand — **cb-release** has the flow and its failure modes.

**The mono Docker image** packs Postgres, NATS, Redis, backend, workers, and
nginx into one container:
```bash
make docker-build    # Dockerfile.mono -> $(DOCKER_REGISTRY):$(cat VERSION)
```

Facts about `Dockerfile.mono` that are easy to get wrong:

- The runtime base is **`python:3.12-slim-bookworm`** — Debian, not Alpine.
  Only the frontend builder stage uses Alpine. Use `apt-get`, not `apk`.
- The container **intentionally starts as root** so the entrypoint can fix
  volume ownership and wire the embedded services; supervisord then drops
  application processes to `breaker:1000` (uid/gid 1000, no home, nologin).
  There is a `checkov:skip=CKV_DOCKER_3` on that line explaining it. Do not
  "fix" this by adding a top-level `USER breaker` — the bootstrap breaks.
- `VOLUME ["/data"]` is the only persistent path.
- `HEALTHCHECK` targets **`/livez`**, deliberately not `/health` or `/readyz`:
  a failing healthcheck restarts the container, and readiness failing during a
  slow dependency start must not become a restart loop.
- Multi-arch is amd64 + arm64, built as **separate per-platform jobs** joined
  with `buildx imagetools create` in `release.yml` — not one `--platform`
  invocation. The combined build was OOM-killed; the comment there records why.
  Preserve that split.

## Secrets, vault, and air-gap

Credentials for integrations are Fernet-encrypted at rest under `CB_VAULT_KEY`
via `services/vault_service.py`, with the API surface in `api/vault.py`. The key
auto-rotates on a daily APScheduler job that re-encrypts stored credentials and
hot-swaps the in-memory vault; `cb-security-hardening` covers the rotation
contract in detail.

Rules that keep this safe:

- Encrypt before the value reaches the database. Never log a credential, and
  never echo one back in an API response.
- Secrets come from env, never from the image or a default in compose.
  `CB_DB_PASSWORD`, `CB_VAULT_KEY`, `CB_JWT_SECRET`, and `NATS_AUTH_TOKEN` all
  use `${VAR:?...}` so a missing one fails the container at start rather than
  booting something insecure.
- `CB_AIRGAP=true` must block outbound network calls. Any new integration that
  reaches the internet needs to honor it, plus the CIDR allowlist in
  `core/network_acl.py`. Air-gapped homelabs are a first-class deployment here,
  not an edge case.
- Credential changes belong in the audit log.

`gitleaks` runs as a pre-commit hook. If it blocks a commit, remove the secret
and rotate it — the value is already in your working tree's history if you
staged it, so bypassing the hook is never the fix.

```bash
make security-check    # gate mode — fails on HIGH/CRIT
make security-report   # full report, non-blocking
```
