0003 — CI gate and coverage ratchet

Context

The bootstrap workflow ran four steps inline (deps.get --check-locked, format --check-formatted, compile --warnings-as-errors, test --warnings-as-errors). A list duplicated between CI and local devs drifts: a check added in one place is silently absent in the other. DashFloat also wants static analysis (Credo, Dialyzer) and coverage tracking from the start rather than retrofitted over a grown codebase.

Decision

One gate alias. mix ci in mix.exs is the single list of checks, and CI invokes only that alias — never the steps individually. Locally, mix ci is the same command: the alias is pinned to :test via cli/preferred_envs, so a dev-env invocation works without a MIX_ENV prefix. Checks: deps.get --check-locked, format --check-formatted, compile --warnings-as-errors, credo --strict, dialyzer, coveralls.multiple --type json --type html.

deps.get --check-locked fetches a missing deps/ itself, so CI has no mix deps.get bootstrap step: a plain deps.get first would silently rewrite a stale lock, leaving the check-locked gate passing on a lockfile it just repaired. NB: the alias runs under MIX_ENV=test, so the lock check sees the dev+test dep set — a future only: :dev dep would need its own care.

Cache keying on resolved versions. Both caches key on setup-beam's elixir-version/otp-version outputs — the versions actually installed — not the requested "1.20.x"/"29" strings, plus hashFiles('mix.lock'). A runner patch bump therefore busts the cache instead of restoring a binary-incompatible build. Dialyzer's PLTs get a separate cache (dialyzer- prefix): the project PLT in priv/plts and the Erlang/Elixir core PLTs in ~/.mix — a PLT is a binary tied to the exact OTP patch, and a stale restore fails with "Old PLT file", while leaving the core PLTs out would rebuild them on every fresh runner.

Coverage floor as a ratchet. coveralls.json sets minimum_coverage to the codebase's measured coverage — enforced by ExCoveralls on every gate run. Introduced at 100 (the bootstrap baseline), re-baselined to 94.3 when the TUI skeleton (ADR 0002, #6) merged mid-flight, and to 98.1 with the streaming engine (ADR 0005, #8): the floor tracks the measured baseline of the code that lands, never a wish about it. From here it is a ratchet — raised as coverage improves, never lowered to make a red run green. skip_files excludes test/: test-support code (the loopback SSE server, ADR 0005) is harness, not shipped, and the floor guards lib/.

Wait for CI as the required check. The wait-for-ci umbrella job runs if: always() and passes only when every job in needs succeeded or was skipped. Branch protection requires this check, so a job renamed or added later cannot leave a required check pending forever — the umbrella is the only check guaranteed present. The skipped arm is future-proofing for path-gated jobs.

Codecov upload watchdog. The upload sets fail_ci_if_error: true: an upload failure or a codecov.io outage reds the step instead of silently dropping coverage. It is skipped for fork PRs — they receive no secrets, and a missing token with fail_ci_if_error would red every external contribution. Same-repo Dependabot PRs are skipped for the same reason (#13): GitHub withholds repo secrets from pull_request runs attributed to dependabot[bot] — re-runs included (on PR #9, run 39931638996, the attempt-2 human re-run still sent an empty token), so the skip keys on github.actor, which stays dependabot[bot] on re-runs.

Alternatives considered

Consequences