Skip to content

feat(autopilot): CI/build/Docker optimization — full feature (all 8 phases) - #213

Merged
pacphi merged 24 commits into
mainfrom
develop
Aug 1, 2026
Merged

feat(autopilot): CI/build/Docker optimization — full feature (all 8 phases)#213
pacphi merged 24 commits into
mainfrom
develop

Conversation

@pacphi

@pacphi pacphi commented Jul 31, 2026

Copy link
Copy Markdown
Owner

CI/build/Docker optimization — 8 phases, ready for human review

Do NOT auto-merge. This is the human checkpoint; main is never merged autonomously.

Implements OPTIMIZATION_SPEC.md (from the rust-optimizer audit). 44 files, +2387/−1438.

The headline: your published Docker image could not run

release.yml had no target:, so buildx built each Dockerfile's last stage — development. Every release published a cargo-watch / node-dev-server image to ghcr as :latest, with no compiled binary, no migrations, no entrypoint. Demonstrated: building the frontend without target: yields 207 MB with no nginx; with target: runtime, 69.8 MB with nginx.

Fixing that exposed a chain of latent defects, none of which were in the spec, because the release image had never once been built:

# Defect Found by
1 Build context excluded the vendored ruvector path deps spec F-2
2 No .dockerignore; backend/target is ~74 GB inspection
3 RUST_VERSION build arg silently ignored (no ARG) inspection
4 Dev compose mounted only ./backend, so ../ruvector was unreachable inspection
5 Builder lacked pkg-config/libssl-dev/g++ building it
6 glibc/CXXABI mismatch — image built clean, binary died on startup running it
7 /app/data root-owned under compose → Permission denied Tier-3 review

Defect 6 is the one to understand. rust:1.97-slim silently migrated from bookworm to trixie, so the image built successfully, produced a 72 MB binary, and passed every grep-based check — while the binary died with version `GLIBC_2.39' not found. Only running it caught that. Counter-intuitively the fix was to move the runtime up to trixie, not the builder down: ONNX Runtime ships prebuilt C++ objects needing GCC 13+, so bookworm cannot even link them.

Measured CI improvements

Before After
Time to test signal ~16m ~5m35s
Rust Tests 12m51s 51s (no longer compiles)
Rust Security Audit 3m3s 16s (prebuilt binary)
Frontend builds per PR 3 1

just test runs 1177 tests / 15 binaries — exact parity with make test, verified by execution rather than inference.

Other substantive changes

  • Make → just (ADR-032): 110 recipes, 3 Makefiles deleted, 4 swallowed exit codes fixed (make test and two audit targets could never fail; root download-models exited 0 on real failure).
  • nextest archive: build once concurrently with clippy, run from the artifact — the win is scheduling, not fewer compiles.
  • Toolchain single-sourced from rust-toolchain.toml, then de-duplicated into a composite action (6 copies → 1).
  • Killed perf-gate theater: lighthouse was non-blocking and bundlewatch only echoed warnings; neither ever gated anything.

qe-court: REMAND → fixed → SHIP

First properly-seated cross-vendor panel (GPT jury, Claude author; writerIsNeverJuror satisfied). It returned REMAND with two real charges my own reviews had missed:

  1. The phase-6 Docker paths filter omitted inputs the image COPYs (entrypoint.sh, migrations/, rust-toolchain.toml, ruvector) — packaging regressions could merge unverified.
  2. Live make commands survived in the product onboarding UI, the CLI, and 2 scripts.

Both fixed and independently re-verified as RESOLVED. Full record: .autopilot/court/ci-build-optimization/integration.md. Kill/overturn rounds were not run, so this is short of the full ADR-124 protocol.

⚠️ Open items — 4 need your decision

Item Why it needs you
pl-healthcheck-flag-unhandled Real runtime bug. Compose's healthcheck passes --healthcheck, which main.rs doesn't parse → it spawns a second server and the container never reports healthy. Needs a main.rs change; no phase covered it.
pl-lighthouse-warn-only All 8 assertions are "warn", so the manual Lighthouse job still cannot fail. Flipping to "error" sets your quality bar.
pl-frontend-image-needs-compose-to-run Nothing proves the frontend image serves — it exits 1 standalone. A compose smoke test would close it.
pl-test-debuginfo-tradeoff CARGO_PROFILE_TEST_DEBUG=0 costs file:line in CI backtraces. Easy revert if it bites.

The court's own strongest case against merging: CI proves images build, not that the system runs. A container-start/health smoke test is the single highest-value thing still missing.

Breaking change for developers

just is now required (brew install just / cargo install just / mise use -g just). scripts/setup-prereqs.sh checks for it. No CI workflow, Dockerfile, husky hook, or package.json script invoked make, so automation is unaffected.


Automated by autopilot ci-build-optimization · 8 phases + optimization pass · ready for human review

pacphi added 19 commits July 31, 2026 10:18
`make test` and `make audit` (and thus root `make test`/`make audit`)
could report green even when Vitest tests failed or pnpm audit found
vulnerabilities, because both targets masked their exit code
(`|| true`, `2>/dev/null || echo ...`). Found while wiring up the
autopilot quality gate, which depends on these commands being honest.
The autopilot ci-build-optimization pipeline uses develop as its
integration branch (base) in pr_ci mode. Without develop in
pull_request.branches, phase PRs into develop would trigger zero CI
checks, and autopilot's merge guard treats that as SKIPPED rather than
green — it would refuse to ever merge a phase.
Adds the autopilot stack profile, the 7-phase pipeline plan derived
from OPTIMIZATION_SPEC.md (rust-optimizer's CI/build/Docker/dependency
audit), and the seeded run ledger. autonomy: pr_ci, base: develop
(created on orchestrate's first firing), trunk: main — phase PRs land
on develop; a single develop -> main PR is the human review point for
the full body of work.
…> Just migration)

User request: convert the project's Make-based task runner to just,
scoped as a phase to run after everything else. Investigated first:
just 1.57.0 is already installed locally; no CI workflow, Dockerfile,
or husky hook invokes make directly today — only
.autopilot/profile.yml's commands.* and human-facing docs do. That's
what makes phase 7 need to be strictly last (depends_on: [0..6]): it
rewrites profile.yml's make commands to just commands, which every
earlier phase's gate is still relying on while those phases run.
…ges fixed

The cross-vendor integration court (GPT jury, Claude writer — the first
properly-seated panel in this pipeline) returned REMAND with two charges.
Both were verified against the repo and both were real; both had been
missed by the author's own Tier-3 reviews.

1. FALSE-GREEN RELEASE ASSURANCE. Phase 6's docker.yml pull_request filter
   omitted inputs the image actually COPYs — backend/entrypoint.sh,
   backend/migrations/**, backend/rust-toolchain.toml, and the ruvector
   submodule gitlink. A packaging regression in any of them could merge with
   the Docker jobs never running, defeating the guarantee the trigger exists
   to provide. Filter now covers every COPYed input; backend/src/** stays
   excluded deliberately, with the reason recorded inline.

2. INCOMPLETE TASK-RUNNER MIGRATION. Live `make` commands survived phase 7
   in user-facing places the sweep never covered (it grepped docs and
   `make <target>` in scripts, but not source, and not the `make -C` form):
     - OnboardingFlow.tsx:122 — the PRODUCT UI showed users `make dev`
     - backend/src/main.rs:207 — the CLI printed `Run 'make models'`
     - scripts/setup-ai.sh, scripts/setup-validate.sh — `make -C ...`
   All fixed. A repo-wide sweep across .sh/.rs/.tsx/.ts/.yml/.json now finds
   no live make invocation. cargo check, tsc --noEmit, shellcheck, just lint
   and just format-check all clean afterwards.

A third charge (ADR-032 marked Accepted) was NOT upheld: this repo uses
Accepted for implemented decisions and Proposed for design-only (cf ADR-031).

Court record: .autopilot/court/ci-build-optimization/integration.md
@pacphi

pacphi commented Jul 31, 2026

Copy link
Copy Markdown
Owner Author

Snyk checks have passed. No issues have been found so far.

Status Scan Engine Critical High Medium Low Total (0)
Open Source Security 0 0 0 0 0 issues

💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse.

pacphi added 5 commits July 31, 2026 16:48
1. --healthcheck was never implemented (pl-healthcheck-flag-unhandled)
   docker-compose.yml's backend healthcheck runs `/app/emailibrium
   --healthcheck`, but main.rs parsed no such flag, so it fell through to
   normal startup: the probe launched a SECOND server every 30s and the
   container could never report healthy. It has to be the binary rather than
   an HTTP probe — the runtime image ships neither wget nor curl (verified).

   Implemented as a short-circuit that probes the running server over
   loopback at /api/v1/vectors/health and exits 0/1. PROVEN by diffing old
   vs new images:
     pre-fix : flag ignored -> server startup path -> "unable to open
               database file"
     with fix: "healthcheck: http://127.0.0.1:8080/... unreachable", exit 1
   The old binary was opening the database; the new one only makes an HTTP
   call. The exits-0-when-healthy path needs a live server — see item 3.

2. Lighthouse could not fail (pl-lighthouse-warn-only)
   All 8 assertions were severity "warn", and lhci exits 0 when only warns
   trip — so phase 2's "real pass/fail instead of theater" was cosmetic.
   Measured the actual scores before touching thresholds: performance 0.98,
   accessibility 0.95, best-practices 1.00, seo 0.92, all four metric budgets
   inside limits. Everything passes with headroom, so flipping to "error" was
   free. Re-ran after the flip: lhci exits 0 as a HARD gate.

3. Nothing proved the system RUNS (pl-frontend-image-needs-compose-to-run)
   Added .github/workflows/smoke.yml: builds the images, `docker compose up
   --wait` (which fails unless every healthcheck passes), curls the backend
   health endpoint, and asserts the frontend actually returns HTML — the only
   place that can be checked, since its nginx proxies to a compose-only
   upstream. This is the container-start/health smoke test the integration
   court named as the single highest-value missing thing.

4. Debuginfo tradeoff (pl-test-debuginfo-tradeoff)
   CARGO_PROFILE_TEST_DEBUG was 0, stripping every `at file:line` frame from
   RUST_BACKTRACE output and defeating RUST_BACKTRACE: 1 two lines above it.
   Set to 1 (line-tables-only): keeps file:line at a fraction of full
   debuginfo, so target/ stays lean. MEASURED with a throwaway panicking
   test, since a Mach-O section check is meaningless on macOS (DWARF stays in
   .o files): debug=0 -> 1 frame with file:line; debug=1 -> 7.

Also recorded, NOT fixed: pl-postgres-volume-permissions. `docker compose up
--wait` fails locally on OrbStack because postgres (user 999, read_only,
cap_drop ALL) cannot chmod the root-owned named volume — the same class as
the /app/data bug Tier-3 caught in phase 5. Whether it is real on Linux is
UNRESOLVED; the new smoke job runs on ubuntu-latest and is the authority.
That job may therefore fail on first run. If it does, it has found a genuine
bug rather than a flaw in the test.
… start

The compose stack has never come up. postgres died during initdb with
"could not change permissions of directory /var/lib/postgresql/data",
reproduced identically on ubuntu-latest, so this was never a local quirk.

Root cause is a UID mismatch: postgres:16-alpine runs postgres as uid 70 and
owns /var/lib/postgresql as 70:70, but the service was pinned to 999:999 —
the DEBIAN postgres uid. The config was presumably written for postgres:16
and later switched to -alpine.

cap_add could not compensate: Docker's added capabilities are inert for a
non-root user, because Linux clears effective capabilities on the transition
and Docker sets no ambient capabilities. So the FOWNER in cap_add never
applied, and the container owned nothing it needed to write.

Fix is the uid alone. Every hardening property is retained: non-root,
cap_drop: ALL, read_only, no-new-privileges, tmpfs mounts. redis is left at
999 deliberately — redis:7-alpine really does use that uid, and it reported
healthy in the smoke run.

Found by .github/workflows/smoke.yml on its first execution, which is the
job's entire purpose: docker.yml proves images BUILD, smoke proves the
system STARTS. Nothing had ever tested the latter.
The backend exited 1 half a second after start in the compose smoke test:

  ERROR ONNX embedding model initialization failed: Failed to retrieve
  model.onnx. No embedding provider available.
  Error: ... No fallback provider available in production.

Not a timeout (it died in 0.5s, nowhere near the 300s wait), not the
postgres uid (that fix worked — postgres now starts), and not the base
image (the backend already runs on debian:trixie-slim).

Root cause: fastembed's default cache is `.fastembed_cache` RELATIVE TO CWD,
so /app/.fastembed_cache — which sits on the read-only rootfs, because
docker-compose.yml sets read_only: true and mounts only /tmp (tmpfs) and
/app/data (volume) as writable. embedding.onnx.cache_dir was unset in every
config, so OnnxEmbeddingModel::new never called with_cache_dir() and fastembed
fell back to that unwritable default. The download failed, and the "onnx"
provider has no fallback in a production build, so startup aborted.

Point cache_dir at /app/data/models — the writable named volume, whose uid
1000 ownership was fixed in phase 5 (verified again here). The model now also
persists across restarts rather than re-downloading.

Consequence worth knowing: first start now pulls ~90MB from HuggingFace, so
the smoke test acquires a network dependency and a slower first boot. The
healthcheck's start_period (45s) plus --wait-timeout 300 should cover it;
CI will confirm.

Third layer uncovered by .github/workflows/smoke.yml — after postgres
permissions and before it, nothing had ever started this stack.
… starts

Fourth and (so far) final layer uncovered by the compose smoke test. Two
independent frontend defects, both fatal at startup:

1. `mkdir() "/var/cache/nginx/client_temp" failed (13: Permission denied)`
   The tmpfs mounts were declared without uid/gid. A tmpfs defaults to
   root-owned mode 755 and this container runs as 1000, so nginx could not
   create its cache dirs. Note the backend's /tmp already carried mode=1777
   while none of the frontend's three mounts had anything — the difference
   was never noticed because nothing started the stack.

2. `[emerg] host not found in upstream "backend"`
   nginx resolves proxy_pass targets at CONFIG LOAD, not per request, and the
   frontend had NO depends_on at all — so it started in parallel with the
   backend and lost the race. They do share the frontend-proxy network, so
   this was purely ordering, not isolation; now gated on service_healthy.

These are the same defect SHAPE as the previous three: the compose hardening
(read_only + non-root user + cap_drop ALL) was applied wholesale without
giving each service a writable path it actually owns —
  postgres: root-owned named volume, user uid mismatched the image (999 vs 70)
  backend : fastembed cache resolved onto the read-only rootfs
  frontend: root-owned tmpfs mounts
Every one of them was fatal, and none was observable until .github/workflows/
smoke.yml actually started the stack.
Fifth layer from the compose smoke test. nginx started cleanly and served
traffic — workers up, no emerg — but the container reported unhealthy
forever, so `docker compose up --wait` failed.

nginx.conf declares `listen 80;` only, i.e. IPv4. The nginx image ships
10-listen-on-ipv6-by-default.sh to add the `listen [::]:80;` line, but it
cannot: read_only: true makes /etc/nginx/conf.d/default.conf unwritable, and
the script logs "can not modify ... (read-only file system?)" and gives up
(confirmed in the CI log). The healthcheck then asked for `localhost`, which
under musl can resolve to ::1 first — a port nginx never bound.

Probing 127.0.0.1 explicitly removes the ambiguity. Same reasoning already
applied to the backend's --healthcheck implementation, which deliberately
probes loopback rather than the configured bind host (which may be 0.0.0.0).

Worth noting the failure mode: the service was WORKING the whole time. Only
the probe was wrong — which is the most misleading kind of red, and exactly
what a smoke test exists to surface before it confuses someone at 2am.
@pacphi
pacphi merged commit 64c6cc0 into main Aug 1, 2026
28 checks passed
@pacphi
pacphi deleted the develop branch August 1, 2026 18:44
pacphi added a commit that referenced this pull request Aug 1, 2026
ci-build-optimization finished (8/8 phases, integration PR #213 merged),
so its successor plan takes over as the active pipeline: an evidence-driven
docs accuracy audit and remediation across 6 personas (end user, operator,
API/MCP consumer, contributor, AI agent, decision reader), closing with a
CI drift guardrail.
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