# ADR-ci-001: CI + release pipeline on Gitea Actions ## Status **Accepted (2026-06-12); implemented the same day on the `ci` branch.** Every fork below was settled with the user as the pipeline was built, and each stage was verified live before acceptance: - a throwaway probe workflow established how the runner executes jobs; - the CI image was built and checked locally (runner contract, warm devShell); - the gate ran green (**clippy clean; 2424 tests pass / 0 fail / 1 intentional ignored doctest**); - the release was exercised end-to-end — tag `v0.0.0-citest2` published a Gitea release carrying the static binary (~10 MB) and its `.sha256`. This ADR records the **CI/release pipeline**. The **dev/build environment it runs on** — the nix flake (devShell + reproducible build, pinned Rust 1.95.0) — is **ADR-ci-002** (relocated here from main's ADR-0049); this ADR builds on it rather than restating it. > **Namespacing.** Kept in `docs/ci/adr/` (id `ADR-ci-001`), disjoint from > `main`'s integer ADR sequence, mirroring the website subproject's > `docs/website/adr/`. This avoids the cross-branch number collisions that > previously forced website ADRs to be renumbered (see that namespace's > history note and ADR-0000 "Numbering discipline"). ## Amendment — 2026-06-13: D1 matrix (non-macOS) §3 (Release) below describes the original **single-target** (x86_64 Linux) job. The release is now a **`test` → `build` matrix** over the four non-macOS D1 targets (Linux + Windows × x86_64/aarch64), cross-built with `cargo-zigbuild`. The full decision — tooling, targets, the Windows `synchronization` stub, the matrix shape, and the macOS deferral with its licensing rationale — is recorded in its own record: **[ADR-ci-003](20260613-adr-ci-003.md)**. ## Context The project is near feature-complete and needs CI (`requirements.md` **TT5**; the **CI** item in the deferred list) and a release path for its distributed binaries (**D1**/**D2**/**D3**). The self-hosted Gitea instance (`git.lazyeval.net`) has its Actions runner freshly set up — a first-time in-anger use — with a DinD-capable setup and a reusable `docker-build` template, exercised by a handful of sample workflows. The starting constraints, and what the probe found: - The runner label is **`ci-public`**. A throwaway probe (`ci-probe.yaml`, since removed) established that **jobs run *inside* a container** — `ghcr.io/catthehacker/ubuntu:act-22.04` by default, as **root** — and therefore the runner *host's* nix is **not** on the steps' PATH (`nix NOT on PATH`, `no /nix`). A custom job `container:` *can* be pulled (it pulled `nixos/nix:latest`), but the runner keeps job containers alive with `entrypoint: /bin/sleep` and runs JS actions (e.g. `actions/checkout`) with `node`, so the container must provide **`sleep` + `bash` + `node`** — a bare `nixos/nix` image has none and fails to start. - The reusable template only does `docker build`; it neither runs a Rust gate nor pushes images nor uploads release assets — so a Rust pipeline can't just call it. - The whole motivation (per the user) is for CI to use the project's **nix flake** for its tools rather than relying on whatever the build machine has — i.e. **one toolchain definition shared by dev and CI**. ## Decision ### 1. Toolchain delivery — a baked nix CI image CI gets its toolchain from a **purpose-built job-container image**, not from host nix and not by installing nix per-job: - **Base `node:22-bookworm-slim`.** Debian slim already provides `bash` + coreutils (`sleep`); the `node` tag adds the actions runtime. This satisfies the act_runner job-container contract at a fraction of the size of the catthehacker runner images (chosen on the user's prompt to avoid those multi-GB images), and far more reliably than a bare `nixos/nix` (which can't start). `.gitea/ci-image/Dockerfile`. - **Single-user nix on top**, flakes enabled, with the **flake's devShell pre-warmed** (`nix develop` realizes nixpkgs + the pinned Rust toolchain + `cargo-sweep` + the musl cc into the store). CI then runs `nix develop -c …` against a warm store — the *same* pinned toolchain as dev (ADR-ci-002), reaching a ready toolchain in ~1.4 s. - **Built + pushed by `build-ci-image.yaml`** via the DinD service to the Gitea container registry as `git.lazyeval.net//rdbms-playground-ci`, a **public** package (anonymous pull, no gate-side credentials). It runs only when an image input changes (Dockerfile / `flake.nix` / `flake.lock` / `rust-toolchain.toml`) or on manual dispatch. ### 2. Gate — `ci.yaml` On branch pushes and PRs, a single job runs **inside the CI image**: `nix develop -c cargo clippy --all-targets -- -D warnings` then `nix develop -c cargo test --no-fail-fast`. **`fmt` is deliberately not gated.** The tree isn't clean under stock `rustfmt` (~100 files would change; no `rustfmt.toml` is committed) and reformatting would churn blame across the in-flight website branch and ongoing `main` work — so, by user decision, the gate is **clippy + test** and fmt is revisited on `main` (also recorded in ADR-ci-002). ### 3. Release — `release.yaml` On a `v*` tag, one job in the CI image: 1. **tests** (`cargo test`) — so a tag can never publish untested code, even one pointing at a never-gated commit (user choice over relying solely on the branch gate); 2. **builds the static binary** for **`x86_64-unknown-linux-musl`** (D2: single static binary, no runtime deps). The glibc/nix-store build is non-portable; the musl target with `crt-static` is fully static. rusqlite's `bundled` SQLite C is compiled by a **musl `cc`** (`pkgsCross.musl64`) wired into the flake devShell via `CC_` + `CARGO_TARGET__LINKER`; `[profile.release] strip = "symbols"` trims it (~13 MB → ~10 MB); 3. **publishes** the binary + a `.sha256` to a Gitea release via the API and the auto-provided **`GITEA_TOKEN`** — no third-party action (just `curl` + `node`, both in the image). ### 4. Triggers — branch vs tag hygiene - Gate and image-build are scoped to **branch** pushes (`branches: ['**']`). Tag pushes ignore `paths:` filters and would otherwise spuriously rebuild the unchanged image and re-gate an already-gated commit; the branch filter excludes tags. **`release.yaml` owns tags** (`tags: ['v*']`). - Pushing commits + a tag together still gates the commits (via the branch ref) and releases (via the tag ref) — no lost coverage, no duplicate runs. ### 5. Auth - **Image push:** a dedicated PAT with `write:package`, supplied as the `REGISTRY_USERNAME` / `REGISTRY_TOKEN` Actions secrets (the package owner must match the token's user — an `oli`-namespace push with a different user is refused with `reqPackageAccess`). - **Release publish:** the auto `GITEA_TOKEN` (repo/release scope). ### 6. Scope this iteration — Linux x86_64, step by step The user's target is the full **D1** matrix, approached incrementally. This iteration ships **Linux x86_64 only**; the rest is deferred (below). ## Consequences - **One toolchain, dev and CI.** They build through the same flake and cannot drift. New image rebuilds only when the flake/toolchain/Dockerfile change. - **D2 is met on Linux.** The release artifact is a genuinely static, stripped musl binary that runs with no runtime dependencies. - **DinD is per-job (no layer cache across runs),** so every `build-ci-image` run rebuilds from scratch (~6 min). Acceptable at its trigger frequency; base-pull caching via the `dind-cached` proxy variant is a possible later optimisation. - **The CI image is ~5.5 GB+** (the Rust toolchain closure, now also musl). Pulled once per runner and cached; slimming (multi-stage, prune) is optional. - **Every gate run recompiles the full dependency graph** (warm *toolchain*, cold *deps*; clippy and test don't share artifacts), ~2 min total. Fine for now; dependency/`target` caching is a deferred speed item. - **`GITEA_TOKEN` must retain release scope;** if an instance policy narrows it, the release publish falls back to a repo-scoped PAT secret. ## Alternatives considered - **Run on the runner host's nix.** Rejected — the probe showed steps run in a container where host nix is unreachable. - **Install nix per-job in the default image.** Works but cold every run (slow) and throwaway once the image exists; rejected in favour of the baked image. - **`catthehacker` or bare `nixos/nix` as the base.** catthehacker is a multi-GB runner emulation we don't need; bare `nixos/nix` lacks `sleep`/`bash`/`node` and won't start. `node:22-bookworm-slim` is the small, contract-satisfying middle (user's suggestion). - **A standard `rust:1.95` CI image instead of the flake.** Simpler in CI but a *second* toolchain definition (drift) — counter to the unify-with-dev goal. - **A third-party Gitea release action.** Avoided; the API + auto token keep the release self-contained and debuggable. ## Deferred / out of scope (tracked, step by step) - **D1 matrix:** **macOS only** now (x86_64 + aarch64). The four non-macOS targets shipped via cargo-zigbuild (see the 2026-06-13 amendment); macOS needs Apple's SDK (osxcross + private SDK, or a Mac runner). - **D3 packaging:** Homebrew / Scoop / winget / `cargo-binstall` manifests (and binstall-friendly asset naming/archives). - **Tier 4 (PTY E2E):** still unwired (`requirements.md` **TT4**); the gate runs tiers 1–3 only, so **TT5** ("CI runs all tiers on Linux/macOS/Windows") is partially met — Linux, tiers 1–3. - **CI speed:** dependency/`target` caching (cargo-chef into the image, or `actions/cache`), and image slimming / `dind-cached` base-pull caching. - **Website deploy:** the static site → Cloudflare via Gitea Actions (a separate, simpler workflow on the website branch). - **fmt gate:** revisit on `main` once a `rustfmt` style is chosen. ## Relationship to other decisions - **Builds on ADR-ci-002** (nix flake dev + build env). This ADR adds the musl-target/cc to that flake and consumes it from CI. - **Advances `requirements.md`:** **TT5** (CI runs the tiers — Linux, 1–3), **D2** (static binary — Linux, done), **D1**/**D3** (partial/deferred). - **Mirrors the website subproject's** separate ADR namespace and its static→Cloudflare-via-Gitea-Actions deployment posture (ADR-website-001).