From 9189740028cd344d15987b53dce87480d23c4376 Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Fri, 12 Jun 2026 20:35:39 +0000 Subject: [PATCH 01/22] build(nix): reproducible dev + build env via a flake (ADR-0049) Root flake with two outputs: devShells.default (pinned 1.95.0 toolchain via rust-toolchain.toml + rust-overlay, plus cargo-sweep) and packages.default (rustPlatform.buildRustPackage from the committed Cargo.lock; doCheck=false). flake.lock pins nixpkgs nixos-26.05 / rust-overlay / flake-utils. .envrc (use flake) for direnv parity. Single source of toolchain for dev and the upcoming CI, so they can't drift. Verified through the flake: nix build yields a working binary, clippy clean, 2424 tests pass / 0 fail / 1 intentional ignored doctest. First step toward requirements.md TT5 + D1/D2/D3. --- .envrc | 1 + .gitignore | 6 + docs/adr/0049-nix-flake-dev-and-build-env.md | 127 +++++++++++++++++++ docs/adr/README.md | 1 + flake.lock | 82 ++++++++++++ flake.nix | 80 ++++++++++++ rust-toolchain.toml | 10 ++ 7 files changed, 307 insertions(+) create mode 100644 .envrc create mode 100644 docs/adr/0049-nix-flake-dev-and-build-env.md create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 rust-toolchain.toml diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..3550a30 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake diff --git a/.gitignore b/.gitignore index 6b15ae8..5b370c4 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,12 @@ /target **/*.rs.bk +# Nix +# `nix build` output symlinks (`result`, `result-`), direnv's cached env +/result +/result-* +.direnv/ + # Snapshot test review files *.snap.new *.pending-snap diff --git a/docs/adr/0049-nix-flake-dev-and-build-env.md b/docs/adr/0049-nix-flake-dev-and-build-env.md new file mode 100644 index 0000000..2c2d9aa --- /dev/null +++ b/docs/adr/0049-nix-flake-dev-and-build-env.md @@ -0,0 +1,127 @@ +# ADR-0049: Nix flake for a reproducible dev + build environment + +## Status + +**Accepted (2026-06-12).** Implemented the same day on the `ci` branch: +`flake.nix`, `flake.lock`, `rust-toolchain.toml`, `.envrc`. Verified +end-to-end before acceptance — `nix develop` provides the pinned +toolchain; `nix build .#default` produces a working binary; `cargo +clippy --all-targets -- -D warnings` is clean and `cargo test` is +**2424 passed / 0 failed / 1 ignored** (the ignored item is the +intentional ```` ```ignore ```` doctest at `src/friendly/mod.rs:21`), +all run *through the flake*. This ADR is the dev/build-environment +half of the CI work; the CI **pipeline** itself (runner wiring, +release matrix) is decided separately as it settles. + +## Context + +The project is near feature-complete and CI is finally being set up +(`requirements.md` **TT5**, **CI** in the deferred list). CI must not +depend on whatever Rust/toolchain happens to be installed on the build +machine — that is neither reproducible nor honest about what the build +needs. + +The sibling project **datamage** already solved this with a Nix flake +(its ADR 0046): the flake is the single, version-pinned declaration of +the toolchain, and both the dev shell and CI go through it so they +cannot drift. We adopt the same pattern here. Ours is dramatically +simpler than datamage's — this is a pure-Rust TUI with no Tauri / +WebKitGTK / Node / WASM surface — so the flake carries almost no system +dependencies. + +Two build facts drove the (tiny) dependency set, confirmed from +`Cargo.lock`: + +- **`libsqlite3-sys` is built with `bundled`** → SQLite is compiled + from vendored C, which needs a C compiler. `nixpkgs`' `stdenv` + provides one automatically; nothing is declared for it. +- **`arboard`'s clipboard backend is `x11rb`** — a pure-Rust socket + XCB client that links *no* C X11 libraries. So no X11/`pkg-config` + system inputs are needed to build or test. A live X server is only + required at *runtime* to actually copy; headless sessions fall back + to OSC 52. + +## Decision + +Adopt a **Nix flake** at the repository root as the canonical +declaration of the dev *and* build environment. + +- **`flake.nix`** exposes two outputs (user-chosen 2026-06-12 over a + dev-shell-only variant): + - **`devShells.default`** — the pinned Rust toolchain (from + `rust-toolchain.toml` via `rust-overlay`) plus `cargo-sweep` for + the `target/` build-hygiene discipline (CLAUDE.md / the datamage + ADR 0050 equivalent). + - **`packages.default`** (= `packages.rdbms-playground`) — a + `rustPlatform.buildRustPackage` that produces the binary + reproducibly from the pinned toolchain and the committed + `Cargo.lock` (`cargoLock.lockFile` → `importCargoLock`, which + fetches each dependency by its lockfile checksum: offline, + deterministic, no `cargoHash` to churn). `nix build` yields the + artifact CI's gate/release can consume. +- **`rust-toolchain.toml`** pins an **exact stable release** + (`1.95.0`), not the floating `stable` channel, so `nix flake update` + cannot surprise-bump Rust into new clippy lints that would fail the + `-D warnings` gate (same reasoning as datamage ADR 0046). Components: + `rustfmt` + `clippy`. No coverage/WASM tooling and no + cross-compilation targets yet — those are added when the release + matrix needs them, not before. +- **`flake.lock`** pins every input (`nixpkgs` `nixos-26.05`, + `rust-overlay`, `flake-utils`) to a commit, making the env + bit-reproducible. +- **`.envrc`** contains `use flake` for direnv auto-activation, kept + for parity with datamage even though direnv is not installed on the + current dev VM (entry is via `nix develop`). +- **`packages.default` sets `doCheck = false`.** The test suite is + *not* run during `nix build` — the Nix build sandbox has no `HOME` + and no X server, which fights the project-directory / clipboard + paths the tests touch. Tests run as their own CI stage via + `nix develop -c cargo test`, keeping "build the artifact" and "run + the suite" cleanly separate. +- **The package version is read from `Cargo.toml`** via + `builtins.fromTOML`, so it never drifts from the crate metadata. + +## Consequences + +- **One toolchain definition.** Dev and CI share the exact pinned + toolchain; they cannot drift. New contributors run `nix develop` + (or get auto-activation via direnv) and have the same Rust as CI. +- **D2 (static binary) is unaffected and still pending.** The + `nix build` artifact links the Nix-store glibc *dynamically* — it is + a reproducible build/test artifact, **not** the single static + release binary D2 calls for. Release binaries will target a static + toolchain (e.g. `x86_64-unknown-linux-musl`) in the forthcoming CI + release work; that is a release-step concern, not a dev-shell one. +- **`fmt` is deliberately *not* gated yet.** The tree is not clean + under stock `rustfmt` (~100 files would change; no `rustfmt.toml` is + committed and the code was shaped by something other than default + `rustfmt`). Reformatting churns blame across every file and would + conflict with the in-flight website branch and ongoing `main` work, + so — user decision 2026-06-12 — the `fmt` gate is left out for now + and revisited on `main`. The CI gate is `clippy` + `test`. +- **Engine-name posture (CLAUDE.md) is respected.** The flake's + comments may name SQLite/`rusqlite` where technically necessary + (build-input rationale); no user-facing string is affected. + +## Alternatives considered + +- **Dev-shell only (no build package).** Matches datamage exactly; CI + would `cargo build` inside `nix develop -c`. Rejected (user choice): + a `nix build` package gives a reproducible release artifact straight + from the pinned toolchain, which the release job wants. +- **A standard `rust:1.95` image in CI, flake for dev only.** Simpler + in CI (no nix-in-CI caching to solve), but it is a *second* place + that defines the toolchain — exactly the drift this ADR exists to + prevent. Rejected for the unified-env goal; the nix-in-CI caching + cost is solved in the CI pipeline work instead. +- **`rustup` on the build machine.** The status quo CI would replace — + non-reproducible, machine-dependent, the thing we are eliminating. + +## Relationship to other decisions + +- Mirrors **datamage ADR 0046** (nix flake dev env) and its build + hygiene companion. This is the rdbms-playground analogue, scoped to + a pure-Rust project. +- Feeds the CI pipeline work for `requirements.md` **TT5** (CI runs + the tiers) and the **D1/D2/D3** distribution items (the release + matrix consumes `nix build` / a static target). diff --git a/docs/adr/README.md b/docs/adr/README.md index 2e15522..7d3d36f 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -54,3 +54,4 @@ This directory contains the project's ADRs, recorded per - [ADR-0046 — Schema sidebar focus/navigation mode and responsive input & hint layout (UI #20/#21/#23)](0046-sidebar-navigation-and-responsive-input-hint.md) — **Accepted + implemented 2026-06-10, phased A→B→C** (8 commits `9f5f76b`…`22bec61`; closes Gitea **#20** hint jumpiness, **#21** left-column improvements, **#23** long input — all forks user-confirmed, including the persistent show/hide toggle which is **deferred**: the Ctrl-O peek covers #21's "keystroke to show and hide"). Two decisions landed differently from the draft (recorded inline): relationship data on **`App`** not `SchemaCache` (DB2); the nav overlay clears **only the sidebar strip + a one-column gutter**, panels staying visible behind (DC2). Treats the three UI issues as one coupled decision because they share the terminal's width/height budget. **Phase A (input & hint):** the hint panel's height becomes a function of **terminal geometry, fixed between resizes** (not of hint content), eliminating the #20 jump at its source — measured catalog shows ≥ ~54-col right-column width never needs > 2 hint lines, so 3 lines is a rare narrow-terminal-only case; height buckets `H<40` compact (input 1 row + horizontal scroll / hint 2) vs `H≥40` comfortable (input 2 rows soft-wrap / hint 2), output `Min(5)` honoured first under degradation; input gains horizontal scroll (`input_scroll_offset`, single logical `String` — **not** I1 multi-line) and 2-row soft-wrap display when tall, preserving ADR-0027's 6-col indicator reserve. **Phase B (sidebar):** the 26-col Tables column is **kept but made optional and richer** (not deleted — pedagogy wins ties) — **width-derived session-only** visibility (visible iff width > 90 or a Ctrl-O peek is active — no stored field; hides at width ≤ 90 so the 90-col screencasts drop it; ADR-0015 format untouched), plus a **relationships panel** rendered narrow with endpoints broken at the arrow, ellipsized — a **separate sibling panel** that **overrides S2**'s nested-list extension model (relationships are cross-table). the full records live on a new **`App.relationships`** field (revised from the ADR's original `SchemaCache.relationship_details` at implementation — `SchemaCache` is walker-facing and needs only the names, kept in `relationships: Vec`; details are UI-only, so `App` mirrors `app.tables` and avoids ~23 fixture edits), delivered by `Database::read_all_relationships` + an `AppEvent::RelationshipsRefreshed`; the two left panels split vertically with the relationships panel floored at 5 rows ("(none)" when empty) and capped at 50 % of the column (DB4). **Phase C (navigation mode):** **`Ctrl-O`** enters a focus cycle (Input → Tables → Relationships → Input; `Esc` exits) orthogonal to the ADR-0003 input mode — **`Ctrl-B` was rejected on review as the default tmux prefix** (unreachable inside tmux); the focused panel **expands to ~40–50 cols as a `Clear` overlay** (right panels stay unchanging underneath) and scrolls via **Up/Down (line) + PageUp/PageDown (page)** (context-rebind, reusing the output-scroll viewport mechanism), with an accent focus border; all non-nav keys inert in nav mode (and nav keys inert while a modal is open). Forks all user-chosen: keep-optional-richer (vs remove/narrow); navigation-mode (vs modeless modifier scroll); `Ctrl-O` (Ctrl-B rejected = tmux prefix); overlay (vs layout re-split); inert-non-nav-keys; geometry-fixed hint height; `H<40/≥40` thresholds; session-only persistence; Up/Down line-scroll; **separate relationships panel overriding S2**; **no hint-area toggle** (S4's stale "keyboard-toggleable" claim struck — never implemented, unwanted). A pre-build `/runda` DA pass drove these corrections: caught the `Ctrl-B`/tmux collision, the `SchemaCache` retype that would have broken completion, the 2-row-input/indicator placement, the missing nav-mode key disposition + modal gate, and three unreferenced requirements (S1 evolved, S2 overridden, S4 corrected); also cross-checked open issue **#22** (overlay/annotation layer — separate ADR, adjacent). OOS: true multi-line input (I1); readline shortcuts (I1b); cross-session sidebar persistence; output as a third nav focus; relationship search/edit from the panel; hint-area toggle; #22's annotation layer. Accepted consequence: the 90-col visibility threshold makes a terminal's output *narrower* when widened across the boundary (sidebar appears) - [ADR-0047 — Demonstration overlay layer (keystroke badges + step captions)](0047-demonstration-overlay-layer.md) — **Accepted 2026-06-10; implemented 2026-06-11, phased A→B→C (closes Gitea #22)** (commits `f879d54`→`2d0f4b2`; no `requirements.md` item — tracked by issue + ADR per convention; all forks user-confirmed + a pre-build `/runda` pass that produced 10 tightening findings and a whole-implementation `/runda` pass that returned PASS, no blockers). An in-app **demonstration mode** (`--demo` flag / `RDBMS_PLAYGROUND_DEMO` env, **off by default, zero footprint when off**) that renders two transient overlays so `autocast` screencasts — and live teaching, and a future guided-lesson system — can show otherwise-invisible interactions. **Keystroke badges** (`[TAB]`, `[ENTER]`, `[UP]`, …): **automatic, app-detected** over a fixed set of glyph-less keys (the app already sees every key, so it re-records for free), label via a pure `demo_badge_label(&KeyEvent)`; the badge **auto-expires on a ~1.5 s timer** that extends the runtime's existing time-boxed-`recv` arm condition (`debounce.is_armed() || badge_pending`; expiry `Instant` in the runtime, `App.demo_badge` the render mirror — mirroring the `input` vs `input_indicator` split). **Step captions**: a **stealth, control-code-delimited input buffer** toggled by **`Ctrl+]`** (byte `0x1D` → arrives as `Char('5')+CONTROL`, verified against crossterm 0.29 `parse.rs:110-113`; chosen over `Ctrl+!`, which is **not a single ASCII byte so autocast cannot send it** — the same wall as arrow keys, R4) — typed characters accumulate **invisibly** (prompt untouched, no echo/history), `Backspace` edits, other keys inert, a second `Ctrl+]` **commits** to the caption box (empty commit dismisses); lives in pure-sync `App::update()`, **intercepted before the modal gate** so captions/badges work **over the load picker** (the `#24` projects cast). Both render as **floating flat black-on-yellow rectangles** (solid fill, **no border glyphs** — a one-cell text margin, deliberately unlike the app's bordered panels; user decision post-build, `2d0f4b2`) **at the output panel's inner bottom-right**, drawn **last over modals**, badge **stacked above** the caption, **no layout reflow**; caption **word-wraps to ≤ 3 lines** (3–5 rows), badge fixed 3 rows; clamp/skip guard for tiny terminals; a new **`App.last_output_area: Rect`** (set in `render_output_panel`) gives the top-level draw the anchor. Caption persists **until the next keystroke**; badge suppressed while capturing. Forks all user-chosen: `--demo` activation (vs hidden command / chord); automatic badges (vs scripted); stealth buffer (vs typed-command / preloaded-file); floating bottom-right boxes (vs HUD / banner / subtitle); `Ctrl+]` trigger; wrap-to-3-line captions; ~1.5 s badge / next-keystroke caption timing. Tested test-first across Tier 1 (label fn, capture state machine incl. over-modal + demo-off gate, nearest-deadline helper), Tier 2 (insta snapshots: badge/caption/both-stacked at 90×26 light+dark, short-terminal clamp), Tier 3 (`--demo` plumbing, badge set/suppressed, caption-without-input wiring), CLI (`--demo` parse + env fallback) — with an **honest limit** noted: the `tokio` timer wiring inside `run_loop` is exercised via the pure pieces + Tier-3 plumbing, not a standalone integration test of the timeout (same posture as the existing `IndicatorDebounce`). One intentional, user-acknowledged behaviour: `Ctrl-C` is inert while capturing (every non-`Ctrl+]` key is, by spec). Final tally **2290 passing / 0 failing / 0 skipped** (1 long-standing ignored doctest), clippy clean. OOS: scripted/manual badge push; badges for glyph keys; configurable styling/placement; the guided-lesson system itself (own ADR); cross-session/-switch persistence; localised caption content; arrow-only cast interactions (output-pane scroll); wiring the overlays into the website `casts.mjs` scripts (website-branch follow-up). Implementation phased **A** (`--demo` plumbing) → **B** (badges) → **C** (captions) + a flat-rectangle restyle - [ADR-0048 — `seed` fake-data generation command](0048-seed-fake-data-generation.md) — **Accepted 2026-06-11; Phase 1 + Phase 2 implemented 2026-06-11** (Phase 1 commits `202e25a`→`fbd219b`; design settled with the user across an extended fork dialogue, hardened by a pre-build `/runda` pass (six blockers folded in), a post-implementation `/runda` pass (eight gaps closed — FK/shortid determinism so **D4 holds with no exceptions**, plus six untested ADR decisions), and a Phase-2 pre-build `/runda` pass (which caught the no-date-literal-token reality → the D2 quoted-dates amendment), and a post-implementation `/runda` pass (which added a friendly error for a bounded override on a UNIQUE column — see D2); **2400 tests pass, clippy clean**). Closes `requirements.md` **SD1** and the core of **SD2**; closes the `seed` half of **A1**. **Phase 1 shipped:** whole-row `seed [count] [--seed ]` with realistic name-aware generation (the `fake` crate + a type-gated heuristic catalogue, table-context name disambiguation, hand-rolled `product` generator, bounded dates), identifier + constraint uniqueness incl. junction distinct-combos, FK sampling from existing parent rows (empty-parent error), `IN`-CHECK derivation + complex-CHECK advisory, a required-column block guard, `--seed` reproducibility (serial/FK/shortid all deterministic), undo as one batch step, replay as a data write, a capped auto-show preview, the enum/CHECK advisory, and an O(N) single-transaction insert path. **Phase 2 shipped (2026-06-11):** the `set` override clause (D2 — fixed value / pick-list / `as ` / `between` range, **quoted** dates per the D2 amendment, type-aware, override drops the column from the advisory) and the `
.` column-fill form (D1 form 2 — an UPDATE over existing rows, refusing PK/autogen targets, empty-table no-op, FK/unique-respecting, one undo step), with the new `KNOWN_GENERATORS` vocabulary (D9), a range `Generator`, full completion/highlight (`HighlightClass::Function`)/validity (`IdentSource::Generators`)/help/pedagogy wiring, and the D13 advisory's Phase-2/3 wording. Further SD2 increments (custom generators, NULL injection, multi-locale, recursive auto-seed) out of scope. Closes `requirements.md` **SD1** and the core of **SD2**; closes the `seed` half of **A1** (the other being `hint`/**H2**). A dedicated `seed` command (own AST variant + `do_seed` executor, **both modes**) generating **realistic, name-aware** fake data. Two forms: **`seed
[count]`** (new rows, default **20**, capped) and **`seed
.`** (fill a column on existing rows, an UPDATE). Generation adds the **`fake` crate** (v5, English) driven by a **type-gated, token-matched name-heuristic catalogue** (~30 patterns, documented false-positive guards), with **table-context** disambiguating the `name`/`title` family (`products.name`→product, `users.name`→person, `vendors.name`→company), a **hand-rolled `product` generator** (`fake` has no commerce module), **bounded dates** (`date`/`timestamp`/`dob`/`*_at` recognised, recent windows — never "all of history"), the **identifier family** (`id`/`code`/`ref`/`number`, non-FK/non-PK) → **unique sequential**, and **enum-ish names** (`role`/`status`/`type`/…) left generic + a **post-seed Hint advisory** pointing at `set … in (…)`. A **`set` override clause** — `= value` / `in (a,b,c)` / `as ` / `between a and b` (numeric **and** date), reusing ADR-0026 operators — answers the heuristic-miss case. **`--seed `** makes runs reproducible (and enables exact-value tests). **FK** columns sampled uniformly from existing parent rows (**empty parent → friendly error**, no recursion v1); **junction/compound-PK** tables seeded with **distinct combinations**, capped + noted (SD1). A **required-column block guard** refuses rather than NULL-violate a `NOT NULL` column it can't fill (e.g. `NOT NULL blob`). Full ambient wiring (completion incl. a new generator-name vocabulary highlighted as `tok_function`, hints, `help seed`, ADR-0042 near-miss matrix, ADR-0027 validity); **no DSL→SQL teaching echo** (seed is a utility command, not a SQL twin). Honours **X5** — `do_seed` reuses insert/update *mechanics as helpers*, not by emitting `Command::Insert`. Implementation phased: (1) core whole-row seed → (2) `set` overrides → (3) column-fill. Deferred (future SD2): recursive auto-seed, NULL injection, multi-locale, user-defined custom generators, full per-column report +- [ADR-0049 — Nix flake for a reproducible dev + build environment](0049-nix-flake-dev-and-build-env.md) — **Accepted + implemented 2026-06-12** (`ci` branch; first step of the CI work toward `requirements.md` **TT5** + **D1/D2/D3**). Adopts a root **Nix flake** as the single, version-pinned declaration of the dev *and* build toolchain so CI never relies on whatever Rust is installed on the build machine — mirroring **datamage ADR 0046**, but far simpler (pure-Rust TUI: no Tauri/WebKit/Node/WASM). Two outputs (user-chosen over dev-shell-only): **`devShells.default`** (pinned toolchain via `rust-toolchain.toml` + `rust-overlay`, plus `cargo-sweep`) and **`packages.default`** (a `rustPlatform.buildRustPackage` building the binary reproducibly from the committed `Cargo.lock` via `importCargoLock`; `doCheck = false` — the suite runs as its own `nix develop -c cargo test` stage, not in the HOME/X-less build sandbox; version read from `Cargo.toml` via `fromTOML`). Toolchain pinned to **exact `1.95.0`** (not floating `stable`) so `nix flake update` can't surprise-bump clippy lints past the `-D warnings` gate; components `rustfmt` + `clippy`; **no** cross/WASM targets yet (added when the release matrix needs them). System inputs are nearly empty by design — `libsqlite3-sys` `bundled` needs only the stdenv C compiler; `arboard`→`x11rb` is pure-Rust (no C X11 libs, X server only needed at *runtime*, OSC 52 otherwise). `.envrc` (`use flake`) kept for direnv parity though direnv isn't on the current VM. **Verified before acceptance:** `nix develop` toolchain pinned, `nix build` yields a working binary, clippy clean, **2424 tests pass / 0 fail / 1 intentional ignored doctest** — all through the flake. Consequences: the `nix build` artifact is glibc-**dynamic** (a reproducible build/test artifact, **not** the D2 static release binary — release uses a static target like `x86_64-unknown-linux-musl`, deferred to the CI release work); the **`fmt` gate is deliberately left out for now** (user decision — the tree isn't clean under stock `rustfmt`, ~100 files would churn and conflict with the website/`main` work; revisit on `main`), so the gate is **`clippy` + `test`**. Alternatives rejected: dev-shell-only (no reproducible artifact); a standard `rust:1.95` CI image (a second toolchain definition = drift, the very thing this prevents); `rustup` on the build host (non-reproducible — the status quo being eliminated). The CI **pipeline** itself (runner wiring, release matrix) is decided separately as it settles. diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..69958c3 --- /dev/null +++ b/flake.lock @@ -0,0 +1,82 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1780902259, + "narHash": "sha256-q8yYEC5f1mFlQO9RGna4LTc9QrcvWunX6FYp83munkQ=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "bd0ff2d3eac24699c3664d5966b9ef36f388e2ca", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-26.05", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs", + "rust-overlay": "rust-overlay" + } + }, + "rust-overlay": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1781234414, + "narHash": "sha256-HdA+P4fKRGOomkewnI/Tww5Wz4xK1O7+hDO90YAsPB4=", + "owner": "oxalica", + "repo": "rust-overlay", + "rev": "1d18bfe3de6244c641ca4e8011186d0981b81d76", + "type": "github" + }, + "original": { + "owner": "oxalica", + "repo": "rust-overlay", + "type": "github" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..e5f8fd8 --- /dev/null +++ b/flake.nix @@ -0,0 +1,80 @@ +{ + description = "RDBMS Playground — Rust TUI dev environment + reproducible build"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05"; + rust-overlay = { + url = "github:oxalica/rust-overlay"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = { self, nixpkgs, rust-overlay, flake-utils }: + flake-utils.lib.eachDefaultSystem (system: + let + pkgs = import nixpkgs { + inherit system; + overlays = [ (import rust-overlay) ]; + }; + + # Single source of the Rust toolchain: the rustup toolchain file. + # rust-overlay provisions the exact channel + components declared there, + # so the dev shell and the build package share one pinned toolchain. + rust = pkgs.rust-bin.fromRustupToolchainFile ./rust-toolchain.toml; + + # Read the package version straight from Cargo.toml so it never drifts + # from the crate metadata (no hand-maintained duplicate here). + cargoToml = builtins.fromTOML (builtins.readFile ./Cargo.toml); + + # System build inputs are deliberately tiny — this is a pure-Rust TUI: + # * libsqlite3-sys is built with the `bundled` feature, so SQLite is + # compiled from vendored C. That needs a C compiler, which the + # stdenv provides automatically (no entry required here). + # * arboard's clipboard backend is `x11rb` — a pure-Rust socket XCB + # client. It links no C X11 libraries, so none appear below. A live + # X server is only needed at *runtime* to copy; headless sessions + # fall back to OSC 52. + # If a future dependency introduces a pkg-config / native-lib link, add + # it here (and document why) rather than leaking it into the host env. + nativeBuildInputs = [ ]; + buildInputs = [ ]; + + # `nix build` → the release binary, built reproducibly from the pinned + # toolchain and the committed Cargo.lock (importCargoLock fetches each + # dependency by its lockfile checksum — offline, no cargoHash to churn). + # CI's release job consumes this artifact; the gate's tests run + # separately via `nix develop -c cargo test` (see below), so the package + # build skips the suite — the nix sandbox has no HOME/X server and would + # fight the project-dirs / clipboard paths the tests touch. + rdbms-playground = pkgs.rustPlatform.buildRustPackage { + pname = cargoToml.package.name; + version = cargoToml.package.version; + src = ./.; + cargoLock.lockFile = ./Cargo.lock; + inherit nativeBuildInputs buildInputs; + doCheck = false; + }; + in { + packages.default = rdbms-playground; + packages.rdbms-playground = rdbms-playground; + + devShells.default = pkgs.mkShell { + inherit buildInputs; + nativeBuildInputs = nativeBuildInputs ++ [ + rust + # Dev-disk maintenance: cargo never garbage-collects stale per-hash + # build artifacts, so target/ creeps into the tens of GB (see + # CLAUDE.md "Build hygiene"). cargo-sweep prunes them; run it + # periodically between milestones. + pkgs.cargo-sweep + ]; + + shellHook = '' + echo "RDBMS Playground dev shell ($(uname -s))" + echo " rust: $(rustc --version | cut -d' ' -f1-2)" + echo " cargo: $(cargo --version | cut -d' ' -f1-2)" + ''; + }; + }); +} diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..cabb063 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,10 @@ +[toolchain] +# Pinned to an exact stable release (not the floating "stable" channel) so +# `nix flake update` cannot surprise-bump Rust into new clippy lints that would +# fail the `-D warnings` CI gate. Matches the host toolchain and the datamage +# flake's convention (its ADR 0046). Bump deliberately, in its own commit. +channel = "1.95.0" +# rustfmt + clippy back the `fmt`/`clippy` CI stages; no coverage or WASM +# tooling is needed here (pure-Rust TUI). Cross-compilation targets for the +# eventual D1 release matrix are added when that CI lands, not before. +components = ["rustfmt", "clippy"] From c7ac0c98775a7c1a6a883206939339eaa5c8d6bf Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Fri, 12 Jun 2026 20:35:39 +0000 Subject: [PATCH 02/22] ci: add throwaway runner-probe workflow Diagnostic to determine how the ci-public runner executes jobs and where the nix toolchain is reachable (host vs default container vs a custom container:), so the real gate is built on facts. Delete once the gate lands. --- .gitea/workflows/ci-probe.yaml | 73 ++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 .gitea/workflows/ci-probe.yaml diff --git a/.gitea/workflows/ci-probe.yaml b/.gitea/workflows/ci-probe.yaml new file mode 100644 index 0000000..19fd23a --- /dev/null +++ b/.gitea/workflows/ci-probe.yaml @@ -0,0 +1,73 @@ +# THROWAWAY DIAGNOSTIC — delete once the real gate is wired. +# +# This answers the questions that decide the CI architecture, on facts rather +# than guesses: +# * How does this runner execute a plain job — directly on the host, or inside +# a default container? (-> is "the ci server has nix" reachable from steps?) +# * Is `nix` on PATH where steps run, and does a /nix store persist? +# * Is a docker client/daemon reachable from a plain job (no DinD service)? +# * Does a custom job `container:` work on this rootless runner, and can it pull +# an image (nixos/nix) — i.e. is the "reusable nix image" model viable? +# +# Trigger: push to this branch, or run manually from the Actions UI. +name: ci-probe +on: [push, workflow_dispatch] + +jobs: + # --- Job 1: DEFAULT execution ------------------------------------------- + # No `container:` override — this is whatever environment the runner gives a + # plain job. Tells us where steps actually run and what's already there. + host: + runs-on: ci-public + steps: + - name: identity & environment + run: | + echo "=== uname ==="; uname -a + echo "=== os-release ==="; head -3 /etc/os-release 2>/dev/null || echo "(none)" + echo "=== whoami / id ==="; whoami; id + echo "=== containerized? ===" + if [ -f /.dockerenv ]; then + echo "/.dockerenv PRESENT -> steps run INSIDE a container" + else + echo "/.dockerenv absent" + fi + echo "--- /proc/1/cgroup (first lines) ---"; head -5 /proc/1/cgroup 2>/dev/null || echo "(none)" + + - name: nix availability (the decisive check) + run: | + echo "=== which nix ==="; command -v nix || echo "nix NOT on PATH" + echo "=== nix --version ==="; nix --version 2>/dev/null || echo "(no nix here)" + echo "=== /nix store ==="; ls -ld /nix /nix/store 2>/dev/null || echo "(no /nix)" + echo "=== store path count (persistence hint; high => warm/shared) ===" + ls /nix/store 2>/dev/null | wc -l + + - name: docker availability (without a DinD service) + run: | + echo "=== which docker ==="; command -v docker || echo "docker NOT on PATH" + docker version 2>/dev/null || echo "(no docker client/daemon reachable from a plain job)" + + - name: checkout — does the flake land here? + uses: actions/checkout@v4 + + - name: flake present in this checkout? + run: ls -la flake.nix flake.lock rust-toolchain.toml 2>/dev/null || echo "(flake not on this branch's checkout)" + + # --- Job 2: CUSTOM CONTAINER -------------------------------------------- + # Tests the "reusable nix image" model: run steps inside a pinned nix image. + # Deliberately minimal — no checkout (the checkout action needs node, which a + # bare nixos/nix image lacks; that's a separate concern). If this job's steps + # run at all, custom job containers are viable on this runner. + nix-container: + runs-on: ci-public + container: + image: nixos/nix:latest + steps: + - name: nix inside a pinned container + run: | + echo "=== inside nixos/nix container ===" + nix --version + echo "--- identity ---"; whoami; id; uname -a + echo "--- flakes enabled? ---" + nix --extra-experimental-features 'nix-command flakes' flake --help >/dev/null 2>&1 \ + && echo "flakes usable (with --extra-experimental-features)" \ + || echo "flake subcommand not usable as invoked" From dc63ed66f1a651260ccc7df3fd497c1ab93438b5 Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Fri, 12 Jun 2026 21:08:04 +0000 Subject: [PATCH 03/22] ci: nix CI toolchain image (node-slim base + warmed flake) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Job-container image the gate runs in. node:22-bookworm-slim satisfies the act_runner contract (sleep/bash/node) far more cheaply than the catthehacker images; single-user nix installed on top (pre-create /nix + empty build-users-group so it installs as root in a container) with the flake's devShell pre-warmed — CI enters a ready 1.95.0 toolchain in ~1.4s. Verified by local build. ~5.5GB (rust toolchain closure); dep/ target caching is a noted follow-up. --- .gitea/ci-image/Dockerfile | 65 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 .gitea/ci-image/Dockerfile diff --git a/.gitea/ci-image/Dockerfile b/.gitea/ci-image/Dockerfile new file mode 100644 index 0000000..161a02a --- /dev/null +++ b/.gitea/ci-image/Dockerfile @@ -0,0 +1,65 @@ +# CI toolchain image for rdbms-playground. +# +# Purpose: a SMALL job-container image that +# (a) satisfies the Gitea act_runner job-container contract — /bin/sleep (the +# keep-alive entrypoint), bash (run: steps), node (JS actions such as +# actions/checkout); a bare nixos/nix image has none of these and won't +# even start (verified by the ci-probe run: "/bin/sleep: no such file"); and +# (b) carries the project's pinned nix toolchain with the flake's devShell +# pre-warmed, so CI runs `nix develop -c cargo ...` against a warm store. +# +# Base: node:22-bookworm-slim. Debian slim already provides bash + coreutils +# (sleep); the node tag adds the actions runtime. Far smaller than the +# catthehacker runner images (which bundle a whole GitHub-runner emulation we +# don't need). +FROM node:22-bookworm-slim + +# nix install + flake eval needs these. git because flakes prefer a VCS context +# and tools shell out to it. Drop apt lists to keep the layer small. +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + curl xz-utils ca-certificates git \ + && rm -rf /var/lib/apt/lists/* + +# Single-user nix (--no-daemon): store at /nix owned by root, no daemon/systemd +# needed — the correct mode for a container. The official installer refuses root +# and shells out to `sudo` purely to create /nix; pre-creating it ourselves (we +# ARE root) sidesteps both. Enable flakes globally so every nix invocation (and +# the runner's steps) get nix-command + flakes without flags. +# nix.conf is written FIRST so the installer's own `nix-env` profile step reads +# it: `build-users-group =` (empty) makes single-user nix build as the calling +# user (root) instead of demanding the nixbld group/users a daemon install would +# create; flakes are enabled globally in the same file. +RUN mkdir -m 0755 /nix && chown root:root /nix \ + && mkdir -p /etc/nix \ + && printf 'build-users-group =\nexperimental-features = nix-command flakes\n' > /etc/nix/nix.conf \ + && curl --proto '=https' --tlsv1.2 -sSf -L https://nixos.org/nix/install -o /tmp/nix-install.sh \ + && sh /tmp/nix-install.sh --no-daemon \ + && rm /tmp/nix-install.sh +ENV PATH=/root/.nix-profile/bin:/nix/var/nix/profiles/default/bin:$PATH +# We set PATH directly instead of sourcing the profile, so also point nix at the +# Debian CA bundle (already installed) for substituter HTTPS — otherwise the +# profile-provided NIX_SSL_CERT_FILE is missing and store downloads fail. +ENV NIX_SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt + +# Warm the flake's devShell into the store: realizes nixpkgs + the pinned Rust +# toolchain (rustc/cargo/clippy/rustfmt) + cargo-sweep. Only the inputs that +# determine the shell are copied, so this expensive layer is cached and only +# re-runs when the flake or the toolchain pin changes — not on every source edit. +# (devShell eval is lazy: packages.default — and thus Cargo.toml/Cargo.lock — is +# never forced here, so it needn't be present.) +WORKDIR /warm +COPY flake.nix flake.lock rust-toolchain.toml ./ +RUN nix develop -c rustc --version \ + && nix develop -c cargo --version \ + && nix develop -c cargo clippy --version \ + && nix develop -c cargo fmt --version \ + && nix develop -c cargo sweep --version +WORKDIR / +RUN rm -rf /warm + +# FOLLOW-UP optimisation (intentionally NOT done here, see CI notes): cargo +# dependency + target caching. Each CI run still compiles the ~296-crate graph +# from scratch and pulls crate sources from crates.io. A later pass can bake +# `cargo fetch` (offline crate sources) and/or a warmed target dir, or wire +# sccache, to cut run time. Correctness/first-green first; speed next. From 9d8161218ad5bc44d03c4661432d6261f428ed76 Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Fri, 12 Jun 2026 21:08:04 +0000 Subject: [PATCH 04/22] ci: gate workflow + CI-image build/push, drop probe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - build-ci-image.yaml: builds .gitea/ci-image/Dockerfile via DinD and pushes git.lazyeval.net/oli/rdbms-playground-ci:latest (REGISTRY_* secrets); triggers on image-input changes + manual dispatch. - ci.yaml: the gate — runs inside that image, clippy -D warnings + cargo test, on push/PR. fmt intentionally not gated (ADR-0049). Removes ci-probe.yaml; it answered the runner questions (jobs run in containers, host nix unreachable, custom container: works). --- .gitea/workflows/build-ci-image.yaml | 47 ++++++++++++++++++ .gitea/workflows/ci-probe.yaml | 73 ---------------------------- .gitea/workflows/ci.yaml | 24 +++++++++ 3 files changed, 71 insertions(+), 73 deletions(-) create mode 100644 .gitea/workflows/build-ci-image.yaml delete mode 100644 .gitea/workflows/ci-probe.yaml create mode 100644 .gitea/workflows/ci.yaml diff --git a/.gitea/workflows/build-ci-image.yaml b/.gitea/workflows/build-ci-image.yaml new file mode 100644 index 0000000..9cea8bb --- /dev/null +++ b/.gitea/workflows/build-ci-image.yaml @@ -0,0 +1,47 @@ +# Builds the nix CI toolchain image (.gitea/ci-image/Dockerfile) and pushes it +# to the Gitea registry. The gate (ci.yaml) runs *inside* this image, so this +# workflow is the gate's prerequisite. It only needs to run when the image's +# inputs change — the Dockerfile, the flake, or the toolchain pin — plus on +# manual dispatch. +# +# DinD pattern: plain docker:27-dind (one of the tested ci-test samples). No +# registry proxy here — the runner's containers have direct internet egress +# (the ci-probe run cloned github.com and pulled docker.io with no proxy), and +# this image's RUN steps fetch from apt + nixos.org, which the proxy isn't +# guaranteed to forward. The dind-cached:local + REGISTRY_PROXY_HOST variant is +# a later speed optimisation for base-image pull caching, not needed for green. +name: build-ci-image +on: + push: + paths: + - '.gitea/ci-image/Dockerfile' + - 'flake.nix' + - 'flake.lock' + - 'rust-toolchain.toml' + - '.gitea/workflows/build-ci-image.yaml' + workflow_dispatch: + +jobs: + build: + runs-on: ci-public + services: + docker: + image: docker:27-dind + options: --privileged + env: + DOCKER_TLS_CERTDIR: "" + env: + DOCKER_HOST: tcp://docker:2375 + IMAGE: git.lazyeval.net/oli/rdbms-playground-ci + steps: + - uses: actions/checkout@v4 + - name: wait for docker + run: until docker version >/dev/null 2>&1; do sleep 1; done + - name: registry login + run: | + echo "${{ secrets.REGISTRY_TOKEN }}" \ + | docker login git.lazyeval.net -u "${{ secrets.REGISTRY_USERNAME }}" --password-stdin + - name: build + run: docker build -f .gitea/ci-image/Dockerfile -t "$IMAGE:latest" . + - name: push + run: docker push "$IMAGE:latest" diff --git a/.gitea/workflows/ci-probe.yaml b/.gitea/workflows/ci-probe.yaml deleted file mode 100644 index 19fd23a..0000000 --- a/.gitea/workflows/ci-probe.yaml +++ /dev/null @@ -1,73 +0,0 @@ -# THROWAWAY DIAGNOSTIC — delete once the real gate is wired. -# -# This answers the questions that decide the CI architecture, on facts rather -# than guesses: -# * How does this runner execute a plain job — directly on the host, or inside -# a default container? (-> is "the ci server has nix" reachable from steps?) -# * Is `nix` on PATH where steps run, and does a /nix store persist? -# * Is a docker client/daemon reachable from a plain job (no DinD service)? -# * Does a custom job `container:` work on this rootless runner, and can it pull -# an image (nixos/nix) — i.e. is the "reusable nix image" model viable? -# -# Trigger: push to this branch, or run manually from the Actions UI. -name: ci-probe -on: [push, workflow_dispatch] - -jobs: - # --- Job 1: DEFAULT execution ------------------------------------------- - # No `container:` override — this is whatever environment the runner gives a - # plain job. Tells us where steps actually run and what's already there. - host: - runs-on: ci-public - steps: - - name: identity & environment - run: | - echo "=== uname ==="; uname -a - echo "=== os-release ==="; head -3 /etc/os-release 2>/dev/null || echo "(none)" - echo "=== whoami / id ==="; whoami; id - echo "=== containerized? ===" - if [ -f /.dockerenv ]; then - echo "/.dockerenv PRESENT -> steps run INSIDE a container" - else - echo "/.dockerenv absent" - fi - echo "--- /proc/1/cgroup (first lines) ---"; head -5 /proc/1/cgroup 2>/dev/null || echo "(none)" - - - name: nix availability (the decisive check) - run: | - echo "=== which nix ==="; command -v nix || echo "nix NOT on PATH" - echo "=== nix --version ==="; nix --version 2>/dev/null || echo "(no nix here)" - echo "=== /nix store ==="; ls -ld /nix /nix/store 2>/dev/null || echo "(no /nix)" - echo "=== store path count (persistence hint; high => warm/shared) ===" - ls /nix/store 2>/dev/null | wc -l - - - name: docker availability (without a DinD service) - run: | - echo "=== which docker ==="; command -v docker || echo "docker NOT on PATH" - docker version 2>/dev/null || echo "(no docker client/daemon reachable from a plain job)" - - - name: checkout — does the flake land here? - uses: actions/checkout@v4 - - - name: flake present in this checkout? - run: ls -la flake.nix flake.lock rust-toolchain.toml 2>/dev/null || echo "(flake not on this branch's checkout)" - - # --- Job 2: CUSTOM CONTAINER -------------------------------------------- - # Tests the "reusable nix image" model: run steps inside a pinned nix image. - # Deliberately minimal — no checkout (the checkout action needs node, which a - # bare nixos/nix image lacks; that's a separate concern). If this job's steps - # run at all, custom job containers are viable on this runner. - nix-container: - runs-on: ci-public - container: - image: nixos/nix:latest - steps: - - name: nix inside a pinned container - run: | - echo "=== inside nixos/nix container ===" - nix --version - echo "--- identity ---"; whoami; id; uname -a - echo "--- flakes enabled? ---" - nix --extra-experimental-features 'nix-command flakes' flake --help >/dev/null 2>&1 \ - && echo "flakes usable (with --extra-experimental-features)" \ - || echo "flake subcommand not usable as invoked" diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml new file mode 100644 index 0000000..cdf32f9 --- /dev/null +++ b/.gitea/workflows/ci.yaml @@ -0,0 +1,24 @@ +# The CI gate. Runs inside the prebuilt nix toolchain image (built + pushed by +# build-ci-image.yaml), so the pinned 1.95.0 toolchain is already warm — steps +# just enter the flake devShell and run cargo. +# +# Gate = clippy + test. fmt is deliberately NOT gated yet (ADR-0049: the tree +# isn't clean under stock rustfmt; revisit on main). The release job (static +# binary for D2) and the platform matrix layer on later, step by step. +name: ci +on: + push: + pull_request: + +jobs: + gate: + runs-on: ci-public + # Public package → anonymous pull, no credentials needed. + container: + image: git.lazyeval.net/oli/rdbms-playground-ci:latest + steps: + - uses: actions/checkout@v4 + - name: clippy (warnings denied) + run: nix develop -c cargo clippy --all-targets -- -D warnings + - name: test + run: nix develop -c cargo test --no-fail-fast From 8e3208528ec85c085d1d8cf267c2453e32c3ceeb Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Fri, 12 Jun 2026 21:43:23 +0000 Subject: [PATCH 05/22] build: static musl release build capability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit rust-toolchain.toml gains the x86_64-unknown-linux-musl target; the flake devShell gains a musl cc (pkgsCross.musl64) + CC/linker env so a `cargo build --target …-musl` compiles rusqlite's bundled SQLite C and links fully static (D2: single static binary, no runtime deps). Cargo release profile strips symbols (13MB -> 10MB). Verified locally: the musl binary is static-pie, statically linked, stripped, runs standalone. --- Cargo.toml | 6 ++++++ flake.nix | 15 +++++++++++++++ rust-toolchain.toml | 9 +++++++-- 3 files changed, 28 insertions(+), 2 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 10c5fd4..32408ec 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -68,6 +68,12 @@ tempfile = "3.27.0" incremental = false debug = "line-tables-only" +# Release builds back the distributed binaries (D2: single static binary). +# strip = "symbols" drops the symbol table at link time so the shipped artifact +# is lean (≈13 MB → 10 MB for the musl build) without a separate strip step. +[profile.release] +strip = "symbols" + [lints.rust] unsafe_code = "forbid" unreachable_pub = "warn" diff --git a/flake.nix b/flake.nix index e5f8fd8..77e74d3 100644 --- a/flake.nix +++ b/flake.nix @@ -27,6 +27,13 @@ # from the crate metadata (no hand-maintained duplicate here). cargoToml = builtins.fromTOML (builtins.readFile ./Cargo.toml); + # musl-targeting C compiler for the static release build: rusqlite's + # `bundled` SQLite is compiled from C, so a `--target …-musl` cargo build + # needs a musl `cc` for that C, plus a musl linker. `targetPrefix` is + # "x86_64-unknown-linux-musl-", so the wrapped binary is + # `${muslCC}/bin/x86_64-unknown-linux-musl-cc`. + muslCC = pkgs.pkgsCross.musl64.stdenv.cc; + # System build inputs are deliberately tiny — this is a pure-Rust TUI: # * libsqlite3-sys is built with the `bundled` feature, so SQLite is # compiled from vendored C. That needs a C compiler, which the @@ -68,9 +75,17 @@ # CLAUDE.md "Build hygiene"). cargo-sweep prunes them; run it # periodically between milestones. pkgs.cargo-sweep + # musl cc/linker for the static release build (see muslCC above). + muslCC ]; + # Point cargo's musl target at the musl cc for both the bundled-C + # compile (CC_, consumed by the `cc` crate) and the final link + # (CARGO_TARGET__LINKER). Harmless for normal glibc builds — + # these only take effect when building `--target x86_64-…-musl`. shellHook = '' + export CC_x86_64_unknown_linux_musl="${muslCC}/bin/${muslCC.targetPrefix}cc" + export CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_LINKER="${muslCC}/bin/${muslCC.targetPrefix}cc" echo "RDBMS Playground dev shell ($(uname -s))" echo " rust: $(rustc --version | cut -d' ' -f1-2)" echo " cargo: $(cargo --version | cut -d' ' -f1-2)" diff --git a/rust-toolchain.toml b/rust-toolchain.toml index cabb063..68cd111 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -5,6 +5,11 @@ # flake's convention (its ADR 0046). Bump deliberately, in its own commit. channel = "1.95.0" # rustfmt + clippy back the `fmt`/`clippy` CI stages; no coverage or WASM -# tooling is needed here (pure-Rust TUI). Cross-compilation targets for the -# eventual D1 release matrix are added when that CI lands, not before. +# tooling is needed here (pure-Rust TUI). components = ["rustfmt", "clippy"] +# x86_64 musl for the static release binary (D2: single static binary, no +# runtime deps). The glibc default links the host/nix-store glibc dynamically +# and isn't portable; the musl target with crt-static produces a fully static +# binary. Further D1 matrix targets (aarch64, windows-gnu, …) are added as the +# release matrix expands, step by step. +targets = ["x86_64-unknown-linux-musl"] From 88145225cc49d7343190e776c745de80bada50f5 Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Fri, 12 Jun 2026 21:43:23 +0000 Subject: [PATCH 06/22] =?UTF-8?q?ci:=20release=20workflow=20=E2=80=94=20st?= =?UTF-8?q?atic=20binary=20to=20Gitea=20releases=20on=20tag?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On a v* tag, builds the x86_64-unknown-linux-musl binary in the CI image and publishes it (+ .sha256) to a Gitea release via the API and the auto GITEA_TOKEN. x86_64 Linux only for now; rest of the D1 matrix and D3 packaging layer on later. Correctness comes from the branch gate. --- .gitea/workflows/release.yaml | 69 +++++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 .gitea/workflows/release.yaml diff --git a/.gitea/workflows/release.yaml b/.gitea/workflows/release.yaml new file mode 100644 index 0000000..4db084d --- /dev/null +++ b/.gitea/workflows/release.yaml @@ -0,0 +1,69 @@ +# Release: on a version tag, build the static Linux binary (D2) and publish it +# to a Gitea release with a checksum. Runs in the same prebuilt CI image as the +# gate, so the pinned toolchain + musl target/cc are already warm. +# +# Scope: x86_64-unknown-linux-musl only, for now. The rest of the D1 matrix +# (aarch64, macOS, Windows) and the D3 package-manager manifests layer on later, +# step by step. +# +# Correctness comes from the branch gate (clippy + test) that ran when the +# tagged commit was pushed; this job builds + publishes, it does not re-test. +name: release +on: + push: + tags: + - 'v*' + +jobs: + release: + runs-on: ci-public + container: + image: git.lazyeval.net/oli/rdbms-playground-ci:latest + env: + TARGET: x86_64-unknown-linux-musl + steps: + - uses: actions/checkout@v4 + + - name: build static binary + run: nix develop -c cargo build --release --target "$TARGET" + + - name: package artifacts + run: | + set -euo pipefail + BIN="target/$TARGET/release/rdbms-playground" + file "$BIN" + OUT="rdbms-playground-${{ github.ref_name }}-$TARGET" + mkdir -p dist + cp "$BIN" "dist/$OUT" + ( cd dist && sha256sum "$OUT" > "$OUT.sha256" ) + ls -l dist + + - name: publish gitea release + assets + env: + # Auto-provided by Gitea Actions; has repo write (release) scope. + TOKEN: ${{ secrets.GITEA_TOKEN }} + API: ${{ github.server_url }}/api/v1 + REPO: ${{ github.repository }} + TAG: ${{ github.ref_name }} + run: | + set -euo pipefail + # Create the release for this tag; if it already exists, look it up. + created=$(curl -sS -X POST "$API/repos/$REPO/releases" \ + -H "Authorization: token $TOKEN" \ + -H "Content-Type: application/json" \ + -d "{\"tag_name\":\"$TAG\",\"name\":\"$TAG\",\"body\":\"Automated release for $TAG.\"}") + id=$(printf '%s' "$created" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{try{const o=JSON.parse(s);process.stdout.write(String(o.id||""))}catch(e){}})') + if [ -z "$id" ]; then + id=$(curl -sS "$API/repos/$REPO/releases/tags/$TAG" \ + -H "Authorization: token $TOKEN" \ + | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{process.stdout.write(String(JSON.parse(s).id))})') + fi + echo "release id: $id" + for f in dist/*; do + name=$(basename "$f") + echo "uploading $name" + curl -sS -X POST "$API/repos/$REPO/releases/$id/assets?name=$name" \ + -H "Authorization: token $TOKEN" \ + -F "attachment=@$f" > /dev/null + done + echo "published $TAG" From bba24120f1bd43a1eea5fdf438bc806a9bf80121 Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Fri, 12 Jun 2026 22:11:24 +0000 Subject: [PATCH 07/22] ci: scope gate + image-build to branch pushes (skip tags) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tag pushes ignore paths: filters, so a release tag spuriously rebuilt the unchanged CI image and re-ran the gate on a commit the branch push already gated. Add branches: ['**'] to both push triggers — tag pushes no longer fire them (release.yaml owns tags). Pushing commits + a tag together still gates the commits via the branch push. --- .gitea/workflows/build-ci-image.yaml | 4 ++++ .gitea/workflows/ci.yaml | 5 +++++ 2 files changed, 9 insertions(+) diff --git a/.gitea/workflows/build-ci-image.yaml b/.gitea/workflows/build-ci-image.yaml index 9cea8bb..a2ede71 100644 --- a/.gitea/workflows/build-ci-image.yaml +++ b/.gitea/workflows/build-ci-image.yaml @@ -13,6 +13,10 @@ name: build-ci-image on: push: + # Branch pushes only. Tag pushes ignore `paths:` filters and would rebuild + # the (unchanged) image on every release tag — `branches: ['**']` excludes + # tags, so this runs only when a branch push actually changes an image input. + branches: ['**'] paths: - '.gitea/ci-image/Dockerfile' - 'flake.nix' diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index cdf32f9..0505252 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -8,6 +8,11 @@ name: ci on: push: + # Branch pushes only — a tag push hits the same commit the branch push + # already gated, so `branches: ['**']` drops the redundant tag-triggered + # run (the release workflow owns tags). Pushing commits + a tag together + # still gates the commits via the branch push. + branches: ['**'] pull_request: jobs: From 89b9392c2595b0b9f2e07e23c7d45f65a31b25d2 Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Fri, 12 Jun 2026 22:11:24 +0000 Subject: [PATCH 08/22] =?UTF-8?q?ci:=20release=20job=20=E2=80=94=20test=20?= =?UTF-8?q?before=20publish,=20pin=20bash,=20fix=20diagnostic?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Run cargo test before the build so a tag never publishes untested code. - Pin shell: bash on the scripted steps; the runner defaults to dash, which rejected `set -o pipefail` and failed run 22's package step. - Swap `file` (absent in the slim image) for `ls -l`. --- .gitea/workflows/release.yaml | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/.gitea/workflows/release.yaml b/.gitea/workflows/release.yaml index 4db084d..98f7fe4 100644 --- a/.gitea/workflows/release.yaml +++ b/.gitea/workflows/release.yaml @@ -6,8 +6,8 @@ # (aarch64, macOS, Windows) and the D3 package-manager manifests layer on later, # step by step. # -# Correctness comes from the branch gate (clippy + test) that ran when the -# tagged commit was pushed; this job builds + publishes, it does not re-test. +# Tests run here before the build so a tag can never publish untested code, +# even one pointing at a commit that was never gated on a branch. name: release on: push: @@ -24,14 +24,20 @@ jobs: steps: - uses: actions/checkout@v4 + - name: test + run: nix develop -c cargo test --no-fail-fast + - name: build static binary run: nix develop -c cargo build --release --target "$TARGET" - name: package artifacts + # Pin bash: the runner defaults scripted steps to dash, which rejects + # `set -o pipefail`. bash is in the CI image. + shell: bash run: | set -euo pipefail BIN="target/$TARGET/release/rdbms-playground" - file "$BIN" + ls -l "$BIN" OUT="rdbms-playground-${{ github.ref_name }}-$TARGET" mkdir -p dist cp "$BIN" "dist/$OUT" @@ -39,6 +45,7 @@ jobs: ls -l dist - name: publish gitea release + assets + shell: bash env: # Auto-provided by Gitea Actions; has repo write (release) scope. TOKEN: ${{ secrets.GITEA_TOKEN }} From da8bfebc36d6cf1579d94eece5ebcaf6ef5e6b02 Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Fri, 12 Jun 2026 22:38:34 +0000 Subject: [PATCH 09/22] docs(ci): establish docs/ci/adr namespace (ci-001 pipeline, ci-002 flake) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Records the CI/release pipeline as ADR-ci-001 and relocates the nix-flake ADR from main's ADR-0049 to ADR-ci-002 (content unchanged, history note added). Both live in docs/ci/adr/ with a README index — a dated, ci-segmented namespace disjoint from main's integer ADR sequence, the same split the website subproject uses to avoid cross-branch number collisions. Drops the ADR-0049 entry from docs/adr/README. ci-001 covers the runner model, the baked nix CI image, the clippy+test gate, the static-musl release on tag, trigger hygiene, auth, and scope. --- docs/adr/README.md | 1 - docs/ci/adr/20260612-adr-ci-001.md | 185 ++++++++++++++++++ .../adr/20260612-adr-ci-002.md} | 20 +- docs/ci/adr/README.md | 22 +++ 4 files changed, 221 insertions(+), 7 deletions(-) create mode 100644 docs/ci/adr/20260612-adr-ci-001.md rename docs/{adr/0049-nix-flake-dev-and-build-env.md => ci/adr/20260612-adr-ci-002.md} (88%) create mode 100644 docs/ci/adr/README.md diff --git a/docs/adr/README.md b/docs/adr/README.md index 7d3d36f..2e15522 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -54,4 +54,3 @@ This directory contains the project's ADRs, recorded per - [ADR-0046 — Schema sidebar focus/navigation mode and responsive input & hint layout (UI #20/#21/#23)](0046-sidebar-navigation-and-responsive-input-hint.md) — **Accepted + implemented 2026-06-10, phased A→B→C** (8 commits `9f5f76b`…`22bec61`; closes Gitea **#20** hint jumpiness, **#21** left-column improvements, **#23** long input — all forks user-confirmed, including the persistent show/hide toggle which is **deferred**: the Ctrl-O peek covers #21's "keystroke to show and hide"). Two decisions landed differently from the draft (recorded inline): relationship data on **`App`** not `SchemaCache` (DB2); the nav overlay clears **only the sidebar strip + a one-column gutter**, panels staying visible behind (DC2). Treats the three UI issues as one coupled decision because they share the terminal's width/height budget. **Phase A (input & hint):** the hint panel's height becomes a function of **terminal geometry, fixed between resizes** (not of hint content), eliminating the #20 jump at its source — measured catalog shows ≥ ~54-col right-column width never needs > 2 hint lines, so 3 lines is a rare narrow-terminal-only case; height buckets `H<40` compact (input 1 row + horizontal scroll / hint 2) vs `H≥40` comfortable (input 2 rows soft-wrap / hint 2), output `Min(5)` honoured first under degradation; input gains horizontal scroll (`input_scroll_offset`, single logical `String` — **not** I1 multi-line) and 2-row soft-wrap display when tall, preserving ADR-0027's 6-col indicator reserve. **Phase B (sidebar):** the 26-col Tables column is **kept but made optional and richer** (not deleted — pedagogy wins ties) — **width-derived session-only** visibility (visible iff width > 90 or a Ctrl-O peek is active — no stored field; hides at width ≤ 90 so the 90-col screencasts drop it; ADR-0015 format untouched), plus a **relationships panel** rendered narrow with endpoints broken at the arrow, ellipsized — a **separate sibling panel** that **overrides S2**'s nested-list extension model (relationships are cross-table). the full records live on a new **`App.relationships`** field (revised from the ADR's original `SchemaCache.relationship_details` at implementation — `SchemaCache` is walker-facing and needs only the names, kept in `relationships: Vec`; details are UI-only, so `App` mirrors `app.tables` and avoids ~23 fixture edits), delivered by `Database::read_all_relationships` + an `AppEvent::RelationshipsRefreshed`; the two left panels split vertically with the relationships panel floored at 5 rows ("(none)" when empty) and capped at 50 % of the column (DB4). **Phase C (navigation mode):** **`Ctrl-O`** enters a focus cycle (Input → Tables → Relationships → Input; `Esc` exits) orthogonal to the ADR-0003 input mode — **`Ctrl-B` was rejected on review as the default tmux prefix** (unreachable inside tmux); the focused panel **expands to ~40–50 cols as a `Clear` overlay** (right panels stay unchanging underneath) and scrolls via **Up/Down (line) + PageUp/PageDown (page)** (context-rebind, reusing the output-scroll viewport mechanism), with an accent focus border; all non-nav keys inert in nav mode (and nav keys inert while a modal is open). Forks all user-chosen: keep-optional-richer (vs remove/narrow); navigation-mode (vs modeless modifier scroll); `Ctrl-O` (Ctrl-B rejected = tmux prefix); overlay (vs layout re-split); inert-non-nav-keys; geometry-fixed hint height; `H<40/≥40` thresholds; session-only persistence; Up/Down line-scroll; **separate relationships panel overriding S2**; **no hint-area toggle** (S4's stale "keyboard-toggleable" claim struck — never implemented, unwanted). A pre-build `/runda` DA pass drove these corrections: caught the `Ctrl-B`/tmux collision, the `SchemaCache` retype that would have broken completion, the 2-row-input/indicator placement, the missing nav-mode key disposition + modal gate, and three unreferenced requirements (S1 evolved, S2 overridden, S4 corrected); also cross-checked open issue **#22** (overlay/annotation layer — separate ADR, adjacent). OOS: true multi-line input (I1); readline shortcuts (I1b); cross-session sidebar persistence; output as a third nav focus; relationship search/edit from the panel; hint-area toggle; #22's annotation layer. Accepted consequence: the 90-col visibility threshold makes a terminal's output *narrower* when widened across the boundary (sidebar appears) - [ADR-0047 — Demonstration overlay layer (keystroke badges + step captions)](0047-demonstration-overlay-layer.md) — **Accepted 2026-06-10; implemented 2026-06-11, phased A→B→C (closes Gitea #22)** (commits `f879d54`→`2d0f4b2`; no `requirements.md` item — tracked by issue + ADR per convention; all forks user-confirmed + a pre-build `/runda` pass that produced 10 tightening findings and a whole-implementation `/runda` pass that returned PASS, no blockers). An in-app **demonstration mode** (`--demo` flag / `RDBMS_PLAYGROUND_DEMO` env, **off by default, zero footprint when off**) that renders two transient overlays so `autocast` screencasts — and live teaching, and a future guided-lesson system — can show otherwise-invisible interactions. **Keystroke badges** (`[TAB]`, `[ENTER]`, `[UP]`, …): **automatic, app-detected** over a fixed set of glyph-less keys (the app already sees every key, so it re-records for free), label via a pure `demo_badge_label(&KeyEvent)`; the badge **auto-expires on a ~1.5 s timer** that extends the runtime's existing time-boxed-`recv` arm condition (`debounce.is_armed() || badge_pending`; expiry `Instant` in the runtime, `App.demo_badge` the render mirror — mirroring the `input` vs `input_indicator` split). **Step captions**: a **stealth, control-code-delimited input buffer** toggled by **`Ctrl+]`** (byte `0x1D` → arrives as `Char('5')+CONTROL`, verified against crossterm 0.29 `parse.rs:110-113`; chosen over `Ctrl+!`, which is **not a single ASCII byte so autocast cannot send it** — the same wall as arrow keys, R4) — typed characters accumulate **invisibly** (prompt untouched, no echo/history), `Backspace` edits, other keys inert, a second `Ctrl+]` **commits** to the caption box (empty commit dismisses); lives in pure-sync `App::update()`, **intercepted before the modal gate** so captions/badges work **over the load picker** (the `#24` projects cast). Both render as **floating flat black-on-yellow rectangles** (solid fill, **no border glyphs** — a one-cell text margin, deliberately unlike the app's bordered panels; user decision post-build, `2d0f4b2`) **at the output panel's inner bottom-right**, drawn **last over modals**, badge **stacked above** the caption, **no layout reflow**; caption **word-wraps to ≤ 3 lines** (3–5 rows), badge fixed 3 rows; clamp/skip guard for tiny terminals; a new **`App.last_output_area: Rect`** (set in `render_output_panel`) gives the top-level draw the anchor. Caption persists **until the next keystroke**; badge suppressed while capturing. Forks all user-chosen: `--demo` activation (vs hidden command / chord); automatic badges (vs scripted); stealth buffer (vs typed-command / preloaded-file); floating bottom-right boxes (vs HUD / banner / subtitle); `Ctrl+]` trigger; wrap-to-3-line captions; ~1.5 s badge / next-keystroke caption timing. Tested test-first across Tier 1 (label fn, capture state machine incl. over-modal + demo-off gate, nearest-deadline helper), Tier 2 (insta snapshots: badge/caption/both-stacked at 90×26 light+dark, short-terminal clamp), Tier 3 (`--demo` plumbing, badge set/suppressed, caption-without-input wiring), CLI (`--demo` parse + env fallback) — with an **honest limit** noted: the `tokio` timer wiring inside `run_loop` is exercised via the pure pieces + Tier-3 plumbing, not a standalone integration test of the timeout (same posture as the existing `IndicatorDebounce`). One intentional, user-acknowledged behaviour: `Ctrl-C` is inert while capturing (every non-`Ctrl+]` key is, by spec). Final tally **2290 passing / 0 failing / 0 skipped** (1 long-standing ignored doctest), clippy clean. OOS: scripted/manual badge push; badges for glyph keys; configurable styling/placement; the guided-lesson system itself (own ADR); cross-session/-switch persistence; localised caption content; arrow-only cast interactions (output-pane scroll); wiring the overlays into the website `casts.mjs` scripts (website-branch follow-up). Implementation phased **A** (`--demo` plumbing) → **B** (badges) → **C** (captions) + a flat-rectangle restyle - [ADR-0048 — `seed` fake-data generation command](0048-seed-fake-data-generation.md) — **Accepted 2026-06-11; Phase 1 + Phase 2 implemented 2026-06-11** (Phase 1 commits `202e25a`→`fbd219b`; design settled with the user across an extended fork dialogue, hardened by a pre-build `/runda` pass (six blockers folded in), a post-implementation `/runda` pass (eight gaps closed — FK/shortid determinism so **D4 holds with no exceptions**, plus six untested ADR decisions), and a Phase-2 pre-build `/runda` pass (which caught the no-date-literal-token reality → the D2 quoted-dates amendment), and a post-implementation `/runda` pass (which added a friendly error for a bounded override on a UNIQUE column — see D2); **2400 tests pass, clippy clean**). Closes `requirements.md` **SD1** and the core of **SD2**; closes the `seed` half of **A1**. **Phase 1 shipped:** whole-row `seed
[count] [--seed ]` with realistic name-aware generation (the `fake` crate + a type-gated heuristic catalogue, table-context name disambiguation, hand-rolled `product` generator, bounded dates), identifier + constraint uniqueness incl. junction distinct-combos, FK sampling from existing parent rows (empty-parent error), `IN`-CHECK derivation + complex-CHECK advisory, a required-column block guard, `--seed` reproducibility (serial/FK/shortid all deterministic), undo as one batch step, replay as a data write, a capped auto-show preview, the enum/CHECK advisory, and an O(N) single-transaction insert path. **Phase 2 shipped (2026-06-11):** the `set` override clause (D2 — fixed value / pick-list / `as ` / `between` range, **quoted** dates per the D2 amendment, type-aware, override drops the column from the advisory) and the `
.` column-fill form (D1 form 2 — an UPDATE over existing rows, refusing PK/autogen targets, empty-table no-op, FK/unique-respecting, one undo step), with the new `KNOWN_GENERATORS` vocabulary (D9), a range `Generator`, full completion/highlight (`HighlightClass::Function`)/validity (`IdentSource::Generators`)/help/pedagogy wiring, and the D13 advisory's Phase-2/3 wording. Further SD2 increments (custom generators, NULL injection, multi-locale, recursive auto-seed) out of scope. Closes `requirements.md` **SD1** and the core of **SD2**; closes the `seed` half of **A1** (the other being `hint`/**H2**). A dedicated `seed` command (own AST variant + `do_seed` executor, **both modes**) generating **realistic, name-aware** fake data. Two forms: **`seed
[count]`** (new rows, default **20**, capped) and **`seed
.`** (fill a column on existing rows, an UPDATE). Generation adds the **`fake` crate** (v5, English) driven by a **type-gated, token-matched name-heuristic catalogue** (~30 patterns, documented false-positive guards), with **table-context** disambiguating the `name`/`title` family (`products.name`→product, `users.name`→person, `vendors.name`→company), a **hand-rolled `product` generator** (`fake` has no commerce module), **bounded dates** (`date`/`timestamp`/`dob`/`*_at` recognised, recent windows — never "all of history"), the **identifier family** (`id`/`code`/`ref`/`number`, non-FK/non-PK) → **unique sequential**, and **enum-ish names** (`role`/`status`/`type`/…) left generic + a **post-seed Hint advisory** pointing at `set … in (…)`. A **`set` override clause** — `= value` / `in (a,b,c)` / `as ` / `between a and b` (numeric **and** date), reusing ADR-0026 operators — answers the heuristic-miss case. **`--seed `** makes runs reproducible (and enables exact-value tests). **FK** columns sampled uniformly from existing parent rows (**empty parent → friendly error**, no recursion v1); **junction/compound-PK** tables seeded with **distinct combinations**, capped + noted (SD1). A **required-column block guard** refuses rather than NULL-violate a `NOT NULL` column it can't fill (e.g. `NOT NULL blob`). Full ambient wiring (completion incl. a new generator-name vocabulary highlighted as `tok_function`, hints, `help seed`, ADR-0042 near-miss matrix, ADR-0027 validity); **no DSL→SQL teaching echo** (seed is a utility command, not a SQL twin). Honours **X5** — `do_seed` reuses insert/update *mechanics as helpers*, not by emitting `Command::Insert`. Implementation phased: (1) core whole-row seed → (2) `set` overrides → (3) column-fill. Deferred (future SD2): recursive auto-seed, NULL injection, multi-locale, user-defined custom generators, full per-column report -- [ADR-0049 — Nix flake for a reproducible dev + build environment](0049-nix-flake-dev-and-build-env.md) — **Accepted + implemented 2026-06-12** (`ci` branch; first step of the CI work toward `requirements.md` **TT5** + **D1/D2/D3**). Adopts a root **Nix flake** as the single, version-pinned declaration of the dev *and* build toolchain so CI never relies on whatever Rust is installed on the build machine — mirroring **datamage ADR 0046**, but far simpler (pure-Rust TUI: no Tauri/WebKit/Node/WASM). Two outputs (user-chosen over dev-shell-only): **`devShells.default`** (pinned toolchain via `rust-toolchain.toml` + `rust-overlay`, plus `cargo-sweep`) and **`packages.default`** (a `rustPlatform.buildRustPackage` building the binary reproducibly from the committed `Cargo.lock` via `importCargoLock`; `doCheck = false` — the suite runs as its own `nix develop -c cargo test` stage, not in the HOME/X-less build sandbox; version read from `Cargo.toml` via `fromTOML`). Toolchain pinned to **exact `1.95.0`** (not floating `stable`) so `nix flake update` can't surprise-bump clippy lints past the `-D warnings` gate; components `rustfmt` + `clippy`; **no** cross/WASM targets yet (added when the release matrix needs them). System inputs are nearly empty by design — `libsqlite3-sys` `bundled` needs only the stdenv C compiler; `arboard`→`x11rb` is pure-Rust (no C X11 libs, X server only needed at *runtime*, OSC 52 otherwise). `.envrc` (`use flake`) kept for direnv parity though direnv isn't on the current VM. **Verified before acceptance:** `nix develop` toolchain pinned, `nix build` yields a working binary, clippy clean, **2424 tests pass / 0 fail / 1 intentional ignored doctest** — all through the flake. Consequences: the `nix build` artifact is glibc-**dynamic** (a reproducible build/test artifact, **not** the D2 static release binary — release uses a static target like `x86_64-unknown-linux-musl`, deferred to the CI release work); the **`fmt` gate is deliberately left out for now** (user decision — the tree isn't clean under stock `rustfmt`, ~100 files would churn and conflict with the website/`main` work; revisit on `main`), so the gate is **`clippy` + `test`**. Alternatives rejected: dev-shell-only (no reproducible artifact); a standard `rust:1.95` CI image (a second toolchain definition = drift, the very thing this prevents); `rustup` on the build host (non-reproducible — the status quo being eliminated). The CI **pipeline** itself (runner wiring, release matrix) is decided separately as it settles. diff --git a/docs/ci/adr/20260612-adr-ci-001.md b/docs/ci/adr/20260612-adr-ci-001.md new file mode 100644 index 0000000..4f34a05 --- /dev/null +++ b/docs/ci/adr/20260612-adr-ci-001.md @@ -0,0 +1,185 @@ +# 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"). + +## 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:** aarch64, macOS, Windows builds (cross toolchains; macOS is the + hard part on a Linux 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). diff --git a/docs/adr/0049-nix-flake-dev-and-build-env.md b/docs/ci/adr/20260612-adr-ci-002.md similarity index 88% rename from docs/adr/0049-nix-flake-dev-and-build-env.md rename to docs/ci/adr/20260612-adr-ci-002.md index 2c2d9aa..6976b1f 100644 --- a/docs/adr/0049-nix-flake-dev-and-build-env.md +++ b/docs/ci/adr/20260612-adr-ci-002.md @@ -1,4 +1,4 @@ -# ADR-0049: Nix flake for a reproducible dev + build environment +# ADR-ci-002: Nix flake for a reproducible dev + build environment ## Status @@ -10,8 +10,15 @@ clippy --all-targets -- -D warnings` is clean and `cargo test` is **2424 passed / 0 failed / 1 ignored** (the ignored item is the intentional ```` ```ignore ```` doctest at `src/friendly/mod.rs:21`), all run *through the flake*. This ADR is the dev/build-environment -half of the CI work; the CI **pipeline** itself (runner wiring, -release matrix) is decided separately as it settles. +foundation; the CI **pipeline** that consumes it (runner model, image, +gate, release) is **ADR-ci-001**. + +> **History.** Created as **ADR-0049** in `main`'s integer ADR namespace +> (`docs/adr/`); moved here to **ADR-ci-002** on 2026-06-12 to keep the +> CI/dev-env decisions out of `main`'s sequence and end the cross-branch +> number collision (`main` independently reaches for the next integer too — +> the same problem the website subproject hit). Content is otherwise +> unchanged. See ADR-0000 "Numbering discipline". ## Context @@ -122,6 +129,7 @@ declaration of the dev *and* build environment. - Mirrors **datamage ADR 0046** (nix flake dev env) and its build hygiene companion. This is the rdbms-playground analogue, scoped to a pure-Rust project. -- Feeds the CI pipeline work for `requirements.md` **TT5** (CI runs - the tiers) and the **D1/D2/D3** distribution items (the release - matrix consumes `nix build` / a static target). +- Feeds **ADR-ci-001** (the CI + release pipeline), which consumes this + flake for `requirements.md` **TT5** (CI runs the tiers) and the + **D1/D2/D3** distribution items (the release uses a static musl target + built through this flake). diff --git a/docs/ci/adr/README.md b/docs/ci/adr/README.md new file mode 100644 index 0000000..38aa095 --- /dev/null +++ b/docs/ci/adr/README.md @@ -0,0 +1,22 @@ +# CI / Build Architecture Decision Records + +Decision records for the **continuous-integration + release pipeline** +subproject — the Gitea Actions workflows under `.gitea/`, the nix CI image, +and the release tooling. These are kept in their own namespace, separate +from the project-wide ADRs in [`docs/adr/`](../../adr/README.md), so CI +decisions never compete with the main global ADR sequence for numbers — the +same split the website subproject uses (`docs/website/adr/`, on the `website` +branch), and for the same reason (see +[ADR-0000 "Numbering discipline"](../../adr/0000-record-architecture-decisions.md)). + +**Numbering.** Files are named `-adr-ci-.md` and referenced in +prose as `ADR-ci-NNN`. The `` (the ADR's accepted/created day, +`YYYYMMDD`) plus the `ci` segment keeps the namespace disjoint from `main`'s +integers. Assign the next free `NNN` from this index. Every ADR change +updates this index in the same edit (the ADR-0000 index-upkeep rule applies +here too). + +## Index + +- [ADR-ci-001 — CI + release pipeline on Gitea Actions](20260612-adr-ci-001.md) — **Accepted 2026-06-12** (implemented the same day on the `ci` branch). Establishes the CI/release pipeline on the self-hosted Gitea instance's Actions runner (`ci-public`). **Runner model** (established by a throwaway probe): jobs execute *inside* a container (`catthehacker/ubuntu:act-22.04` by default), as root, so the runner host's nix is **not** reachable from steps. **Toolchain delivery:** a **baked CI image** — `node:22-bookworm-slim` (satisfies the act_runner job-container contract: `/bin/sleep` keep-alive, `bash`, `node` for JS actions; a bare `nixos/nix` image lacks these and won't start) **+ single-user nix + the flake's devShell pre-warmed** — built by `build-ci-image.yaml` via DinD and pushed to the Gitea container registry as a **public** package, so CI runs `nix develop -c …` against the **same pinned toolchain as dev** (the flake, ADR-ci-002) with a warm store (~1.4 s to a ready toolchain). **Gate** (`ci.yaml`): `clippy -D warnings` + `cargo test` inside that image on branch pushes + PRs; **fmt deliberately not gated** (the tree isn't stock-rustfmt-clean — user decision, revisit on `main`; see ADR-ci-002). **Release** (`release.yaml`): on a `v*` tag, runs the tests, builds the **static `x86_64-unknown-linux-musl` binary** (D2: single static binary, no runtime deps — the glibc/nix build is non-portable), strips it, and publishes it + a `.sha256` to a Gitea release via the API and the auto-provided `GITEA_TOKEN`. **Triggers:** gate + image-build are scoped to **branch** pushes (`branches: ['**']`) so a release tag doesn't spuriously re-run them; the image-build additionally path-filters to its inputs (Dockerfile/flake/toolchain); the release owns tags. **Auth:** a dedicated PAT (`REGISTRY_USERNAME`/`REGISTRY_TOKEN` secrets) pushes the image; the auto `GITEA_TOKEN` publishes releases. **Scope this iteration:** Linux x86_64 only — the rest of the D1 matrix (aarch64/macOS/Windows), D3 package-manager manifests, CI-speed dependency caching, and the website's static→Cloudflare deploy are deferred, to be added step by step. Verified live: probe → runner facts; image built + checked locally; gate green (**2424 tests**); release exercised end-to-end (`v0.0.0-citest2` published with binary + checksum). Builds on **ADR-ci-002** (the nix flake, relocated here from main's ADR-0049 to avoid exactly this cross-branch collision). +- [ADR-ci-002 — Nix flake for a reproducible dev + build environment](20260612-adr-ci-002.md) — **Accepted 2026-06-12** (relocated from main's **ADR-0049** on the same day — content unchanged — to keep CI/dev-env decisions out of `main`'s integer sequence). The single, version-pinned declaration of the **dev *and* build toolchain** so CI never relies on whatever Rust is on the build machine — mirroring **datamage ADR 0046**, but far simpler (pure-Rust TUI). Root **Nix flake** with two outputs: **`devShells.default`** (pinned **Rust 1.95.0** via `rust-toolchain.toml` + `rust-overlay`, `cargo-sweep`, and the musl cc for the static release build) and **`packages.default`** (`rustPlatform.buildRustPackage` from the committed `Cargo.lock`; `doCheck = false`). Exact-pin (not floating `stable`) so `nix flake update` can't surprise-bump clippy past the `-D warnings` gate. System inputs near-empty by design (`libsqlite3-sys bundled` → stdenv cc only; `arboard`→`x11rb` pure-Rust). `.envrc` (`use flake`) for direnv parity. Verified through the flake: `nix build` yields a working binary, clippy clean, **2424 tests pass / 0 fail / 1 intentional ignored doctest**. Consumed by **ADR-ci-001** (the pipeline). Alternatives rejected: dev-shell-only; a standard `rust:1.95` CI image (a second toolchain definition = drift); `rustup` on the build host (non-reproducible). From 18d08642d75d92a715fc7cc55aab28b9f8f5122c Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Fri, 12 Jun 2026 22:42:50 +0000 Subject: [PATCH 10/22] ci: skip the gate for docs-only changes Add paths-ignore (docs/**, **/*.md) to the gate's push + pull_request triggers so markdown/docs-only changes don't run a full clippy+test that can't change the outcome. Mixed code+docs pushes still gate (not all files are ignored); flake/toolchain changes are deliberately not ignored. Also refresh a stale ADR-0049 -> ADR-ci-002 comment reference. --- .gitea/workflows/ci.yaml | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index 0505252..8f717f4 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -2,7 +2,7 @@ # build-ci-image.yaml), so the pinned 1.95.0 toolchain is already warm — steps # just enter the flake devShell and run cargo. # -# Gate = clippy + test. fmt is deliberately NOT gated yet (ADR-0049: the tree +# Gate = clippy + test. fmt is deliberately NOT gated yet (ADR-ci-002: the tree # isn't clean under stock rustfmt; revisit on main). The release job (static # binary for D2) and the platform matrix layer on later, step by step. name: ci @@ -13,7 +13,17 @@ on: # run (the release workflow owns tags). Pushing commits + a tag together # still gates the commits via the branch push. branches: ['**'] + # Skip the gate for docs-only changes — markdown can't affect clippy/test. + # A push touching code *and* docs still runs (not all files are ignored). + # Note: flake/toolchain changes are NOT ignored — they can shift the + # toolchain and thus lint/test outcomes. + paths-ignore: + - 'docs/**' + - '**/*.md' pull_request: + paths-ignore: + - 'docs/**' + - '**/*.md' jobs: gate: From 04ebd83f08c70b03b2a292e8cfc8945adc8b076a Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Sat, 13 Jun 2026 12:14:49 +0000 Subject: [PATCH 11/22] build: D1 cross-compile via cargo-zigbuild (4 non-macOS targets) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the single-target musl cc with cargo-zigbuild + zig in the flake devShell — one universal cross cc/linker (incl. rusqlite's bundled SQLite C) for all four non-macOS D1 targets, added to rust-toolchain.toml: x86_64/aarch64-unknown-linux-musl (static, D2) x86_64-pc-windows-gnu, aarch64-pc-windows-gnullvm (standalone .exe) Windows links -lsynchronization (std WaitOnAddress), which rust-overlay's toolchain and zig's mingw don't ship; the symbols are forwarded by kernel32, so an empty stub libsynchronization.a (ci/winstub/, wired via .cargo/config.toml for the windows targets only) satisfies the linker. Verified: all four build; linux static; windows valid PE32+. --- .cargo/config.toml | 17 +++++++++++++++++ ci/winstub/README.md | 30 ++++++++++++++++++++++++++++++ ci/winstub/libsynchronization.a | 1 + flake.nix | 28 +++++++++++----------------- rust-toolchain.toml | 18 ++++++++++++------ 5 files changed, 71 insertions(+), 23 deletions(-) create mode 100644 .cargo/config.toml create mode 100644 ci/winstub/README.md create mode 100644 ci/winstub/libsynchronization.a diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..5f1ba45 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,17 @@ +# Windows cross-link fix for the D1 release matrix (cargo-zigbuild). +# +# Rust's std links `-lsynchronization` on Windows (WaitOnAddress-based thread +# parking). Rust normally satisfies this from the `self-contained` mingw libs +# of its `rust-mingw` component — which rust-overlay does NOT ship — and Zig's +# bundled mingw (used by `cargo zigbuild`) doesn't provide `libsynchronization.a` +# either. The actual symbols are *forwarded by kernel32* (already linked), so an +# empty stub import lib is enough to satisfy the linker. See `ci/winstub/`. +# +# These sections apply ONLY when building for the Windows targets, so host +# builds (the gate's `cargo test`/`clippy`) and the Linux release targets are +# unaffected. +[target.x86_64-pc-windows-gnu] +rustflags = ["-L", "native=ci/winstub"] + +[target.aarch64-pc-windows-gnullvm] +rustflags = ["-L", "native=ci/winstub"] diff --git a/ci/winstub/README.md b/ci/winstub/README.md new file mode 100644 index 0000000..ad05111 --- /dev/null +++ b/ci/winstub/README.md @@ -0,0 +1,30 @@ +# `ci/winstub/` — empty Windows import-lib stub + +`libsynchronization.a` here is an **empty `ar` archive** (8 bytes: `!\n`), +referenced by `.cargo/config.toml` via `-L native=ci/winstub` for the Windows +release targets. + +## Why + +The D1 release matrix cross-compiles Windows binaries from Linux with +`cargo zigbuild` (see `docs/ci/adr/`). Rust's `std` links `-lsynchronization` +for its `WaitOnAddress`-based thread parking. That import library is normally +provided by Rust's `rust-mingw` "self-contained" component — which `rust-overlay` +does not ship — and Zig's bundled mingw doesn't carry it either, so the link +fails with: + +``` +error: unable to find dynamic system library 'synchronization' +``` + +The functions it would import (`WaitOnAddress`, `WakeByAddressSingle`, +`WakeByAddressAll`) are **forwarded by `kernel32.dll`**, which is already linked, +so they resolve at link and run time without a real `synchronization` import +library. An **empty** stub is therefore sufficient: it satisfies the `-l` +lookup and contributes no symbols. + +## Regenerating + +``` +zig ar rcs ci/winstub/libsynchronization.a +``` diff --git a/ci/winstub/libsynchronization.a b/ci/winstub/libsynchronization.a new file mode 100644 index 0000000..8b277f0 --- /dev/null +++ b/ci/winstub/libsynchronization.a @@ -0,0 +1 @@ +! diff --git a/flake.nix b/flake.nix index 77e74d3..6407308 100644 --- a/flake.nix +++ b/flake.nix @@ -27,13 +27,6 @@ # from the crate metadata (no hand-maintained duplicate here). cargoToml = builtins.fromTOML (builtins.readFile ./Cargo.toml); - # musl-targeting C compiler for the static release build: rusqlite's - # `bundled` SQLite is compiled from C, so a `--target …-musl` cargo build - # needs a musl `cc` for that C, plus a musl linker. `targetPrefix` is - # "x86_64-unknown-linux-musl-", so the wrapped binary is - # `${muslCC}/bin/x86_64-unknown-linux-musl-cc`. - muslCC = pkgs.pkgsCross.musl64.stdenv.cc; - # System build inputs are deliberately tiny — this is a pure-Rust TUI: # * libsqlite3-sys is built with the `bundled` feature, so SQLite is # compiled from vendored C. That needs a C compiler, which the @@ -75,20 +68,21 @@ # CLAUDE.md "Build hygiene"). cargo-sweep prunes them; run it # periodically between milestones. pkgs.cargo-sweep - # musl cc/linker for the static release build (see muslCC above). - muslCC + # Cross-compilation for the D1 release matrix. `cargo zigbuild` uses + # Zig's bundled clang + libc as one universal cross cc/linker for + # every non-macOS target (Linux musl x64/arm64, Windows gnu/gnullvm + # x64/arm64) — including the `cc`-crate compile of rusqlite's bundled + # SQLite C — with no per-target toolchain or SDK. It auto-discovers + # `zig` on PATH, so no extra env is needed. + pkgs.cargo-zigbuild + pkgs.zig ]; - # Point cargo's musl target at the musl cc for both the bundled-C - # compile (CC_, consumed by the `cc` crate) and the final link - # (CARGO_TARGET__LINKER). Harmless for normal glibc builds — - # these only take effect when building `--target x86_64-…-musl`. shellHook = '' - export CC_x86_64_unknown_linux_musl="${muslCC}/bin/${muslCC.targetPrefix}cc" - export CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_LINKER="${muslCC}/bin/${muslCC.targetPrefix}cc" echo "RDBMS Playground dev shell ($(uname -s))" - echo " rust: $(rustc --version | cut -d' ' -f1-2)" - echo " cargo: $(cargo --version | cut -d' ' -f1-2)" + echo " rust: $(rustc --version | cut -d' ' -f1-2)" + echo " cargo: $(cargo --version | cut -d' ' -f1-2)" + echo " zig: $(zig version 2>/dev/null || echo '?') (cargo-zigbuild cross targets)" ''; }; }); diff --git a/rust-toolchain.toml b/rust-toolchain.toml index 68cd111..64a9490 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -7,9 +7,15 @@ channel = "1.95.0" # rustfmt + clippy back the `fmt`/`clippy` CI stages; no coverage or WASM # tooling is needed here (pure-Rust TUI). components = ["rustfmt", "clippy"] -# x86_64 musl for the static release binary (D2: single static binary, no -# runtime deps). The glibc default links the host/nix-store glibc dynamically -# and isn't portable; the musl target with crt-static produces a fully static -# binary. Further D1 matrix targets (aarch64, windows-gnu, …) are added as the -# release matrix expands, step by step. -targets = ["x86_64-unknown-linux-musl"] +# The non-macOS D1 release matrix, all cross-built from Linux x86_64 via +# `cargo zigbuild` (D1: cross-platform binaries; D2: single static binary). +# Linux uses musl + crt-static for fully static, portable binaries; Windows +# uses the gnu/gnullvm ABIs (Zig statically links libc, so the .exe is +# standalone). macOS is deferred — its arboard/AppKit link needs Apple's SDK, +# which a Linux runner can't supply cleanly (see docs/ci/adr ADR-ci-001). +targets = [ + "x86_64-unknown-linux-musl", + "aarch64-unknown-linux-musl", + "x86_64-pc-windows-gnu", + "aarch64-pc-windows-gnullvm", +] From 298475b326d560421039c53a65706262c1d9e301 Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Sat, 13 Jun 2026 12:14:49 +0000 Subject: [PATCH 12/22] ci: D1 release matrix over the four non-macOS targets release.yaml becomes test (once, host) -> build (matrix) over the four cargo-zigbuild targets; each matrix job uploads its binary + .sha256 to the shared release (idempotent create-or-get). Records the expansion in ADR-ci-001 (2026-06-13 amendment); macOS stays deferred. --- .gitea/workflows/release.yaml | 76 ++++++++++++++++++------------ docs/ci/adr/20260612-adr-ci-001.md | 34 ++++++++++++- docs/ci/adr/README.md | 2 +- 3 files changed, 79 insertions(+), 33 deletions(-) diff --git a/.gitea/workflows/release.yaml b/.gitea/workflows/release.yaml index 98f7fe4..14110ea 100644 --- a/.gitea/workflows/release.yaml +++ b/.gitea/workflows/release.yaml @@ -1,13 +1,15 @@ -# Release: on a version tag, build the static Linux binary (D2) and publish it -# to a Gitea release with a checksum. Runs in the same prebuilt CI image as the -# gate, so the pinned toolchain + musl target/cc are already warm. +# Release: on a version tag, build the cross-platform binaries and publish them +# to a Gitea release with checksums. Runs in the prebuilt CI image, so the +# pinned toolchain + the release targets + cargo-zigbuild/zig are already warm. # -# Scope: x86_64-unknown-linux-musl only, for now. The rest of the D1 matrix -# (aarch64, macOS, Windows) and the D3 package-manager manifests layer on later, -# step by step. +# Matrix (D1, cross-built from Linux x86_64 via cargo-zigbuild): +# x86_64-unknown-linux-musl aarch64-unknown-linux-musl (static, D2) +# x86_64-pc-windows-gnu aarch64-pc-windows-gnullvm (standalone .exe) +# macOS is deferred — its arboard/AppKit link needs Apple's SDK (see ADR-ci-001). +# D3 package-manager manifests layer on later. # -# Tests run here before the build so a tag can never publish untested code, -# even one pointing at a commit that was never gated on a branch. +# Tests run once (host) before the matrix, so a tag can never publish untested +# code, even one pointing at a commit that was never gated on a branch. name: release on: push: @@ -15,46 +17,59 @@ on: - 'v*' jobs: - release: + test: runs-on: ci-public container: image: git.lazyeval.net/oli/rdbms-playground-ci:latest - env: - TARGET: x86_64-unknown-linux-musl steps: - uses: actions/checkout@v4 - - name: test run: nix develop -c cargo test --no-fail-fast - - name: build static binary - run: nix develop -c cargo build --release --target "$TARGET" + build: + needs: test + runs-on: ci-public + container: + image: git.lazyeval.net/oli/rdbms-playground-ci:latest + strategy: + fail-fast: false + matrix: + target: + - x86_64-unknown-linux-musl + - aarch64-unknown-linux-musl + - x86_64-pc-windows-gnu + - aarch64-pc-windows-gnullvm + steps: + - uses: actions/checkout@v4 - - name: package artifacts + - name: build + run: nix develop -c cargo zigbuild --release --target ${{ matrix.target }} + + - name: package + publish # Pin bash: the runner defaults scripted steps to dash, which rejects # `set -o pipefail`. bash is in the CI image. - shell: bash - run: | - set -euo pipefail - BIN="target/$TARGET/release/rdbms-playground" - ls -l "$BIN" - OUT="rdbms-playground-${{ github.ref_name }}-$TARGET" - mkdir -p dist - cp "$BIN" "dist/$OUT" - ( cd dist && sha256sum "$OUT" > "$OUT.sha256" ) - ls -l dist - - - name: publish gitea release + assets shell: bash env: - # Auto-provided by Gitea Actions; has repo write (release) scope. + TARGET: ${{ matrix.target }} + # GITEA_TOKEN is auto-provided with repo write (release) scope. TOKEN: ${{ secrets.GITEA_TOKEN }} API: ${{ github.server_url }}/api/v1 REPO: ${{ github.repository }} TAG: ${{ github.ref_name }} run: | set -euo pipefail - # Create the release for this tag; if it already exists, look it up. + + # Windows targets produce a .exe; the rest a bare binary. + case "$TARGET" in *windows*) EXT=.exe ;; *) EXT= ;; esac + BIN="target/$TARGET/release/rdbms-playground$EXT" + OUT="rdbms-playground-$TAG-$TARGET$EXT" + mkdir -p dist + cp "$BIN" "dist/$OUT" + ( cd dist && sha256sum "$OUT" > "$OUT.sha256" ) + ls -l dist + + # Create the release for this tag; if a sibling matrix job already + # created it, look it up instead (idempotent + race-tolerant). created=$(curl -sS -X POST "$API/repos/$REPO/releases" \ -H "Authorization: token $TOKEN" \ -H "Content-Type: application/json" \ @@ -66,6 +81,7 @@ jobs: | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{process.stdout.write(String(JSON.parse(s).id))})') fi echo "release id: $id" + for f in dist/*; do name=$(basename "$f") echo "uploading $name" @@ -73,4 +89,4 @@ jobs: -H "Authorization: token $TOKEN" \ -F "attachment=@$f" > /dev/null done - echo "published $TAG" + echo "published $TARGET assets for $TAG" diff --git a/docs/ci/adr/20260612-adr-ci-001.md b/docs/ci/adr/20260612-adr-ci-001.md index 4f34a05..8389a59 100644 --- a/docs/ci/adr/20260612-adr-ci-001.md +++ b/docs/ci/adr/20260612-adr-ci-001.md @@ -24,6 +24,35 @@ it rather than restating it. > previously forced website ADRs to be renumbered (see that namespace's > history note and ADR-0000 "Numbering discipline"). +## Amendment — 2026-06-13: D1 matrix expanded (non-macOS targets) + +The release now builds the **four non-macOS D1 targets**, all cross-compiled +from the Linux x86_64 runner with **`cargo-zigbuild`** (Zig's bundled clang + +libc as one universal cross cc/linker — including the `cc`-crate compile of +rusqlite's bundled SQLite C — added to the flake devShell, replacing the +single-target musl cc): + +- `x86_64-unknown-linux-musl`, `aarch64-unknown-linux-musl` — static (D2); +- `x86_64-pc-windows-gnu`, `aarch64-pc-windows-gnullvm` — standalone `.exe`. + +`release.yaml` became a **`test` (once, host) → `build` (matrix over the four +targets)** workflow; each matrix job uploads its artifact + `.sha256` to the +shared release (idempotent create-or-get). + +**Windows link fix:** Rust's std links `-lsynchronization` (WaitOnAddress +thread-parking), an import lib that rust-overlay's toolchain doesn't ship and +Zig's mingw lacks. Those symbols are forwarded by `kernel32` (already linked), +so an **empty stub** `libsynchronization.a` (committed at `ci/winstub/`, wired +via `.cargo/config.toml` for the Windows targets only) satisfies the linker. +Verified locally: all four build; the Linux binaries are statically linked; the +Windows artifacts are valid PE32+ (x86-64 / Aarch64) — not yet runtime +smoke-tested on Windows. + +**macOS stays deferred** (see Deferred): `arboard`→AppKit needs Apple's SDK, +which a Linux runner can't supply cleanly — and the CI image is *public*, so the +SDK can't be baked in even if the licensing grey area were accepted. macOS is +its own step (osxcross + a private SDK, or a real Mac runner). + ## Context The project is near feature-complete and needs CI (`requirements.md` **TT5**; @@ -162,8 +191,9 @@ iteration ships **Linux x86_64 only**; the rest is deferred (below). ## Deferred / out of scope (tracked, step by step) -- **D1 matrix:** aarch64, macOS, Windows builds (cross toolchains; macOS is the - hard part on a Linux runner). +- **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 diff --git a/docs/ci/adr/README.md b/docs/ci/adr/README.md index 38aa095..0a2339c 100644 --- a/docs/ci/adr/README.md +++ b/docs/ci/adr/README.md @@ -18,5 +18,5 @@ here too). ## Index -- [ADR-ci-001 — CI + release pipeline on Gitea Actions](20260612-adr-ci-001.md) — **Accepted 2026-06-12** (implemented the same day on the `ci` branch). Establishes the CI/release pipeline on the self-hosted Gitea instance's Actions runner (`ci-public`). **Runner model** (established by a throwaway probe): jobs execute *inside* a container (`catthehacker/ubuntu:act-22.04` by default), as root, so the runner host's nix is **not** reachable from steps. **Toolchain delivery:** a **baked CI image** — `node:22-bookworm-slim` (satisfies the act_runner job-container contract: `/bin/sleep` keep-alive, `bash`, `node` for JS actions; a bare `nixos/nix` image lacks these and won't start) **+ single-user nix + the flake's devShell pre-warmed** — built by `build-ci-image.yaml` via DinD and pushed to the Gitea container registry as a **public** package, so CI runs `nix develop -c …` against the **same pinned toolchain as dev** (the flake, ADR-ci-002) with a warm store (~1.4 s to a ready toolchain). **Gate** (`ci.yaml`): `clippy -D warnings` + `cargo test` inside that image on branch pushes + PRs; **fmt deliberately not gated** (the tree isn't stock-rustfmt-clean — user decision, revisit on `main`; see ADR-ci-002). **Release** (`release.yaml`): on a `v*` tag, runs the tests, builds the **static `x86_64-unknown-linux-musl` binary** (D2: single static binary, no runtime deps — the glibc/nix build is non-portable), strips it, and publishes it + a `.sha256` to a Gitea release via the API and the auto-provided `GITEA_TOKEN`. **Triggers:** gate + image-build are scoped to **branch** pushes (`branches: ['**']`) so a release tag doesn't spuriously re-run them; the image-build additionally path-filters to its inputs (Dockerfile/flake/toolchain); the release owns tags. **Auth:** a dedicated PAT (`REGISTRY_USERNAME`/`REGISTRY_TOKEN` secrets) pushes the image; the auto `GITEA_TOKEN` publishes releases. **Scope this iteration:** Linux x86_64 only — the rest of the D1 matrix (aarch64/macOS/Windows), D3 package-manager manifests, CI-speed dependency caching, and the website's static→Cloudflare deploy are deferred, to be added step by step. Verified live: probe → runner facts; image built + checked locally; gate green (**2424 tests**); release exercised end-to-end (`v0.0.0-citest2` published with binary + checksum). Builds on **ADR-ci-002** (the nix flake, relocated here from main's ADR-0049 to avoid exactly this cross-branch collision). +- [ADR-ci-001 — CI + release pipeline on Gitea Actions](20260612-adr-ci-001.md) — **Accepted 2026-06-12** (implemented the same day on the `ci` branch). Establishes the CI/release pipeline on the self-hosted Gitea instance's Actions runner (`ci-public`). **Runner model** (established by a throwaway probe): jobs execute *inside* a container (`catthehacker/ubuntu:act-22.04` by default), as root, so the runner host's nix is **not** reachable from steps. **Toolchain delivery:** a **baked CI image** — `node:22-bookworm-slim` (satisfies the act_runner job-container contract: `/bin/sleep` keep-alive, `bash`, `node` for JS actions; a bare `nixos/nix` image lacks these and won't start) **+ single-user nix + the flake's devShell pre-warmed** — built by `build-ci-image.yaml` via DinD and pushed to the Gitea container registry as a **public** package, so CI runs `nix develop -c …` against the **same pinned toolchain as dev** (the flake, ADR-ci-002) with a warm store (~1.4 s to a ready toolchain). **Gate** (`ci.yaml`): `clippy -D warnings` + `cargo test` inside that image on branch pushes + PRs; **fmt deliberately not gated** (the tree isn't stock-rustfmt-clean — user decision, revisit on `main`; see ADR-ci-002). **Release** (`release.yaml`): on a `v*` tag, runs the tests, builds the **static `x86_64-unknown-linux-musl` binary** (D2: single static binary, no runtime deps — the glibc/nix build is non-portable), strips it, and publishes it + a `.sha256` to a Gitea release via the API and the auto-provided `GITEA_TOKEN`. **Triggers:** gate + image-build are scoped to **branch** pushes (`branches: ['**']`) so a release tag doesn't spuriously re-run them; the image-build additionally path-filters to its inputs (Dockerfile/flake/toolchain); the release owns tags. **Auth:** a dedicated PAT (`REGISTRY_USERNAME`/`REGISTRY_TOKEN` secrets) pushes the image; the auto `GITEA_TOKEN` publishes releases. **Scope:** the **four non-macOS D1 targets** (Linux + Windows × x86_64/aarch64) cross-built from Linux via cargo-zigbuild (2026-06-13 amendment); macOS, D3 package-manager manifests, CI-speed dependency caching, and the website's static→Cloudflare deploy remain deferred, added step by step. Verified live: probe → runner facts; image built + checked locally; gate green (**2424 tests**); release exercised end-to-end (`v0.0.0-citest2` published with binary + checksum). Builds on **ADR-ci-002** (the nix flake, relocated here from main's ADR-0049 to avoid exactly this cross-branch collision). - [ADR-ci-002 — Nix flake for a reproducible dev + build environment](20260612-adr-ci-002.md) — **Accepted 2026-06-12** (relocated from main's **ADR-0049** on the same day — content unchanged — to keep CI/dev-env decisions out of `main`'s integer sequence). The single, version-pinned declaration of the **dev *and* build toolchain** so CI never relies on whatever Rust is on the build machine — mirroring **datamage ADR 0046**, but far simpler (pure-Rust TUI). Root **Nix flake** with two outputs: **`devShells.default`** (pinned **Rust 1.95.0** via `rust-toolchain.toml` + `rust-overlay`, `cargo-sweep`, and the musl cc for the static release build) and **`packages.default`** (`rustPlatform.buildRustPackage` from the committed `Cargo.lock`; `doCheck = false`). Exact-pin (not floating `stable`) so `nix flake update` can't surprise-bump clippy past the `-D warnings` gate. System inputs near-empty by design (`libsqlite3-sys bundled` → stdenv cc only; `arboard`→`x11rb` pure-Rust). `.envrc` (`use flake`) for direnv parity. Verified through the flake: `nix build` yields a working binary, clippy clean, **2424 tests pass / 0 fail / 1 intentional ignored doctest**. Consumed by **ADR-ci-001** (the pipeline). Alternatives rejected: dev-shell-only; a standard `rust:1.95` CI image (a second toolchain definition = drift); `rustup` on the build host (non-reproducible). From 5869eec4f4275d35abf3cc01727edd7f4bba142d Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Sat, 13 Jun 2026 19:11:29 +0000 Subject: [PATCH 13/22] =?UTF-8?q?docs(ci):=20ADR-ci-003=20=E2=80=94=20cros?= =?UTF-8?q?s-platform=20release=20builds=20(D1=20matrix)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Record the multi-platform build strategy as its own decision: cargo-zigbuild for the four non-macOS targets, the static/standalone posture per platform, the Windows synchronization stub, the test->build matrix workflow, and the macOS deferral with its licensing rationale (the public CI image can't carry the SDK). Shrinks the ci-001 amendment to a pointer; updates the index. Runtime-verified by the user: Linux x86_64 + Windows aarch64 run correctly. --- docs/ci/adr/20260612-adr-ci-001.md | 34 ++----- docs/ci/adr/20260613-adr-ci-003.md | 151 +++++++++++++++++++++++++++++ docs/ci/adr/README.md | 3 +- 3 files changed, 160 insertions(+), 28 deletions(-) create mode 100644 docs/ci/adr/20260613-adr-ci-003.md diff --git a/docs/ci/adr/20260612-adr-ci-001.md b/docs/ci/adr/20260612-adr-ci-001.md index 8389a59..1d64ccf 100644 --- a/docs/ci/adr/20260612-adr-ci-001.md +++ b/docs/ci/adr/20260612-adr-ci-001.md @@ -24,34 +24,14 @@ it rather than restating it. > previously forced website ADRs to be renumbered (see that namespace's > history note and ADR-0000 "Numbering discipline"). -## Amendment — 2026-06-13: D1 matrix expanded (non-macOS targets) +## Amendment — 2026-06-13: D1 matrix (non-macOS) -The release now builds the **four non-macOS D1 targets**, all cross-compiled -from the Linux x86_64 runner with **`cargo-zigbuild`** (Zig's bundled clang + -libc as one universal cross cc/linker — including the `cc`-crate compile of -rusqlite's bundled SQLite C — added to the flake devShell, replacing the -single-target musl cc): - -- `x86_64-unknown-linux-musl`, `aarch64-unknown-linux-musl` — static (D2); -- `x86_64-pc-windows-gnu`, `aarch64-pc-windows-gnullvm` — standalone `.exe`. - -`release.yaml` became a **`test` (once, host) → `build` (matrix over the four -targets)** workflow; each matrix job uploads its artifact + `.sha256` to the -shared release (idempotent create-or-get). - -**Windows link fix:** Rust's std links `-lsynchronization` (WaitOnAddress -thread-parking), an import lib that rust-overlay's toolchain doesn't ship and -Zig's mingw lacks. Those symbols are forwarded by `kernel32` (already linked), -so an **empty stub** `libsynchronization.a` (committed at `ci/winstub/`, wired -via `.cargo/config.toml` for the Windows targets only) satisfies the linker. -Verified locally: all four build; the Linux binaries are statically linked; the -Windows artifacts are valid PE32+ (x86-64 / Aarch64) — not yet runtime -smoke-tested on Windows. - -**macOS stays deferred** (see Deferred): `arboard`→AppKit needs Apple's SDK, -which a Linux runner can't supply cleanly — and the CI image is *public*, so the -SDK can't be baked in even if the licensing grey area were accepted. macOS is -its own step (osxcross + a private SDK, or a real Mac runner). +§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 diff --git a/docs/ci/adr/20260613-adr-ci-003.md b/docs/ci/adr/20260613-adr-ci-003.md new file mode 100644 index 0000000..f840fc3 --- /dev/null +++ b/docs/ci/adr/20260613-adr-ci-003.md @@ -0,0 +1,151 @@ +# ADR-ci-003: Cross-platform release builds (the D1 matrix) + +## Status + +**Accepted (2026-06-13); implemented the same day on the `ci` branch.** Every +fork was settled with the user. Verified end-to-end: + +- all four targets cross-build locally from Linux x86_64; +- the Linux binaries are statically linked (D2); the Windows artifacts are + valid PE32+ (x86-64 / Aarch64); +- a real release-matrix run (tag `v.0.0.0-citest3`) published **8 assets** — the + four binaries + a `.sha256` each. + +**Runtime-verified (2026-06-13, by the user):** the **Linux x86_64** and +**Windows aarch64** binaries launch and run correctly — one of each OS family +and both architectures. The remaining two (**Linux aarch64**, **Windows +x86_64**) are link-clean and valid format but not yet runtime smoke-tested. + +This ADR records the **cross-platform build strategy**; it sits on top of +**ADR-ci-002** (the nix flake, which now carries the cross toolchain) and +**ADR-ci-001** (the pipeline, whose release job this fills in). + +## Context + +`requirements.md` **D1** asks for binaries on **Linux, macOS, Windows × x86_64 +and aarch64** (six targets); **D2** asks for a **single static binary, no +runtime deps**. The CI runner executes jobs in a **Linux x86_64** container +(ADR-ci-001), so every target is **cross-compiled from Linux**. + +What's feasible is decided almost entirely by one dependency — **`arboard`** +(the clipboard backend for the `copy` command). Its per-platform backends in +`Cargo.lock`: + +| Target family | arboard backend | Needs a platform SDK to cross-link? | +|---|---|---| +| Linux x86_64 / aarch64 | `x11rb` (pure Rust) | No | +| Windows x86_64 / aarch64 | `clipboard-win` + `windows-sys` (import libs bundled) | No | +| **macOS x86_64 / aarch64** | **`objc2-app-kit` → links AppKit** | **Yes — Apple's SDK** | + +So **four targets cross-compile with no SDK**; **macOS is the hard wall** — +AppKit can only be linked against Apple's SDK. + +## Decision + +### 1. Tooling — `cargo-zigbuild` + +Cross-compile with **`cargo-zigbuild`** (Zig's bundled clang + libc as a single +universal cross `cc`/linker), added to the flake devShell alongside `zig`. One +tool serves every non-macOS target, **including the `cc`-crate compile of +rusqlite's bundled SQLite C**, with no per-target toolchain. It replaced the +earlier single-target musl `cc` (ADR-ci-002's first cut). + +### 2. Targets this iteration — the four non-macOS + +Added to `rust-toolchain.toml` and the release matrix: + +- **`x86_64-unknown-linux-musl`**, **`aarch64-unknown-linux-musl`** — musl + + `crt-static`, so **fully static** portable binaries (D2); +- **`x86_64-pc-windows-gnu`**, **`aarch64-pc-windows-gnullvm`** — Zig statically + links its libc, so the `.exe` is **standalone** (no mingw runtime DLLs). + +### 3. The Windows `synchronization` stub + +Rust's `std` links **`-lsynchronization`** (its `WaitOnAddress`-based thread +parking). That import library is normally supplied by Rust's `rust-mingw` +"self-contained" component — which **rust-overlay does not ship** — and Zig's +mingw doesn't carry it either, so the link fails with *"unable to find dynamic +system library 'synchronization'"*. The functions (`WaitOnAddress`, +`WakeByAddress*`) are **forwarded by `kernel32`** (already linked), so an +**empty stub** `libsynchronization.a` (committed at **`ci/winstub/`**, 8 bytes, +wired via **`.cargo/config.toml`** for the Windows targets *only*) satisfies the +linker without contributing symbols. Host and Linux builds are untouched by it. + +### 4. Workflow shape — test once, then a build matrix + +`release.yaml` is **`test` → `build`**: + +- **`test`** runs once on the host (`cargo test`) — a tag never publishes + untested code; +- **`build`** is a **matrix over the four targets** (`needs: test`, + `fail-fast: false`), each `cargo zigbuild --release --target `, then + packages the binary (`.exe` for Windows) + a `.sha256` and uploads both to the + **shared release** via an **idempotent create-or-get** (the first matrix job + creates the release; the rest fetch it). + +### 5. macOS — deferred, with rationale + +macOS is **not** in this iteration. `arboard`→AppKit needs the macOS SDK, and: + +- the SDK ships **only inside Xcode**; Apple's license ties its use to + **Apple-branded hardware**, so using it on a Linux runner is a **grey area** + (widely done, low enforcement, but technically against the terms); +- **redistributing** the SDK is a clearer violation — and our **CI image is + public**, so the SDK **cannot be baked into it** even if the grey area were + accepted; it would have to live in a private store; +- the **clean** path is building on **real Apple hardware** (a Mac registered as + a Gitea runner, or hosted Mac CI), where the SDK is fully licensed. + +macOS therefore becomes its **own step**, choosing between **(a)** osxcross + a +**private** SDK kept out of the public image, or **(b)** a **Mac runner**. The +user decides when we get there. + +## Consequences + +- **D1: four of six targets met** from a single Linux runner; **D2 met on + Linux** (static musl). Windows `.exe`s are standalone. +- **Runtime coverage:** Linux x86_64 + Windows aarch64 confirmed running + (user, 2026-06-13); Linux aarch64 + Windows x86_64 are the outstanding + runtime checks. +- **Each matrix target recompiles from scratch** (~2–4 min; ~10 min total on the + single runner), and Zig's per-target libc cache is cold each run. Fine at + release frequency; cacheable later if it matters. +- **The empty stub depends on `kernel32` forwarding `WaitOnAddress`** (true on + Windows 8+), which covers every supported target. +- **Asset naming** `rdbms-playground--[.exe]` is close to what + `cargo-binstall` / the D3 package managers will want. + +## Alternatives considered + +- **`cross` (cross-rs).** Docker-image-per-target; covers Linux + Windows but + **not macOS** (no legally redistributable Apple images), and needs DinD + orchestration inside our job. Rejected — no macOS, more moving parts than + zigbuild. +- **Per-target nix cross (`pkgsCross`).** Clean for Linux-musl and + Windows-x86_64 (mingw-w64, which *does* ship `libsynchronization.a`), but + Windows-aarch64 isn't readily packaged and **macOS-from-Linux is unsupported** + in nixpkgs. Rejected — incomplete. +- **Native runners per OS.** Cleanest for macOS/Windows, but needs mac/windows + runners we don't have. Kept on the table specifically for the deferred macOS + step. +- **A real `libsynchronization.a`** (from nixpkgs mingw or a `rust-mingw` + component) instead of the empty stub. More principled, but more flake + machinery, doesn't cover Windows-aarch64, and unnecessary — the stub links + clean because the symbols resolve via `kernel32`. + +## Deferred / out of scope + +- **macOS** (x86_64 + aarch64) — the SDK/runner decision above. +- **D3 packaging** — Homebrew / Scoop / winget / `cargo-binstall` manifests + (and binstall-friendly archive naming). +- **CI speed** — caching per-target builds / Zig's libc cache. +- **Runtime smoke test** of the two not-yet-checked targets (Linux aarch64, + Windows x86_64). + +## Relationship to other decisions + +- **Extends ADR-ci-002** — the flake devShell now carries `cargo-zigbuild` + + `zig` and the four release targets. +- **Fills in ADR-ci-001 §3 (Release)** — that single-target job is now this + matrix. +- **Advances `requirements.md`** **D1** (4/6) and **D2** (Linux, done). diff --git a/docs/ci/adr/README.md b/docs/ci/adr/README.md index 0a2339c..74a2223 100644 --- a/docs/ci/adr/README.md +++ b/docs/ci/adr/README.md @@ -18,5 +18,6 @@ here too). ## Index -- [ADR-ci-001 — CI + release pipeline on Gitea Actions](20260612-adr-ci-001.md) — **Accepted 2026-06-12** (implemented the same day on the `ci` branch). Establishes the CI/release pipeline on the self-hosted Gitea instance's Actions runner (`ci-public`). **Runner model** (established by a throwaway probe): jobs execute *inside* a container (`catthehacker/ubuntu:act-22.04` by default), as root, so the runner host's nix is **not** reachable from steps. **Toolchain delivery:** a **baked CI image** — `node:22-bookworm-slim` (satisfies the act_runner job-container contract: `/bin/sleep` keep-alive, `bash`, `node` for JS actions; a bare `nixos/nix` image lacks these and won't start) **+ single-user nix + the flake's devShell pre-warmed** — built by `build-ci-image.yaml` via DinD and pushed to the Gitea container registry as a **public** package, so CI runs `nix develop -c …` against the **same pinned toolchain as dev** (the flake, ADR-ci-002) with a warm store (~1.4 s to a ready toolchain). **Gate** (`ci.yaml`): `clippy -D warnings` + `cargo test` inside that image on branch pushes + PRs; **fmt deliberately not gated** (the tree isn't stock-rustfmt-clean — user decision, revisit on `main`; see ADR-ci-002). **Release** (`release.yaml`): on a `v*` tag, runs the tests, builds the **static `x86_64-unknown-linux-musl` binary** (D2: single static binary, no runtime deps — the glibc/nix build is non-portable), strips it, and publishes it + a `.sha256` to a Gitea release via the API and the auto-provided `GITEA_TOKEN`. **Triggers:** gate + image-build are scoped to **branch** pushes (`branches: ['**']`) so a release tag doesn't spuriously re-run them; the image-build additionally path-filters to its inputs (Dockerfile/flake/toolchain); the release owns tags. **Auth:** a dedicated PAT (`REGISTRY_USERNAME`/`REGISTRY_TOKEN` secrets) pushes the image; the auto `GITEA_TOKEN` publishes releases. **Scope:** the **four non-macOS D1 targets** (Linux + Windows × x86_64/aarch64) cross-built from Linux via cargo-zigbuild (2026-06-13 amendment); macOS, D3 package-manager manifests, CI-speed dependency caching, and the website's static→Cloudflare deploy remain deferred, added step by step. Verified live: probe → runner facts; image built + checked locally; gate green (**2424 tests**); release exercised end-to-end (`v0.0.0-citest2` published with binary + checksum). Builds on **ADR-ci-002** (the nix flake, relocated here from main's ADR-0049 to avoid exactly this cross-branch collision). +- [ADR-ci-001 — CI + release pipeline on Gitea Actions](20260612-adr-ci-001.md) — **Accepted 2026-06-12** (implemented the same day on the `ci` branch). Establishes the CI/release pipeline on the self-hosted Gitea instance's Actions runner (`ci-public`). **Runner model** (established by a throwaway probe): jobs execute *inside* a container (`catthehacker/ubuntu:act-22.04` by default), as root, so the runner host's nix is **not** reachable from steps. **Toolchain delivery:** a **baked CI image** — `node:22-bookworm-slim` (satisfies the act_runner job-container contract: `/bin/sleep` keep-alive, `bash`, `node` for JS actions; a bare `nixos/nix` image lacks these and won't start) **+ single-user nix + the flake's devShell pre-warmed** — built by `build-ci-image.yaml` via DinD and pushed to the Gitea container registry as a **public** package, so CI runs `nix develop -c …` against the **same pinned toolchain as dev** (the flake, ADR-ci-002) with a warm store (~1.4 s to a ready toolchain). **Gate** (`ci.yaml`): `clippy -D warnings` + `cargo test` inside that image on branch pushes + PRs; **fmt deliberately not gated** (the tree isn't stock-rustfmt-clean — user decision, revisit on `main`; see ADR-ci-002). **Release** (`release.yaml`): on a `v*` tag, runs the tests, builds the **static `x86_64-unknown-linux-musl` binary** (D2: single static binary, no runtime deps — the glibc/nix build is non-portable), strips it, and publishes it + a `.sha256` to a Gitea release via the API and the auto-provided `GITEA_TOKEN`. **Triggers:** gate + image-build are scoped to **branch** pushes (`branches: ['**']`) so a release tag doesn't spuriously re-run them; the image-build additionally path-filters to its inputs (Dockerfile/flake/toolchain); the release owns tags. **Auth:** a dedicated PAT (`REGISTRY_USERNAME`/`REGISTRY_TOKEN` secrets) pushes the image; the auto `GITEA_TOKEN` publishes releases. **Scope:** the original release job was Linux x86_64 only; it's now the **four non-macOS D1 targets** (Linux + Windows × x86_64/aarch64) cross-built via cargo-zigbuild — see **ADR-ci-003**. macOS, D3 package-manager manifests, CI-speed dependency caching, and the website's static→Cloudflare deploy remain deferred, added step by step. Verified live: probe → runner facts; image built + checked locally; gate green (**2424 tests**); release exercised end-to-end (`v0.0.0-citest2` published with binary + checksum). Builds on **ADR-ci-002** (the nix flake, relocated here from main's ADR-0049 to avoid exactly this cross-branch collision). - [ADR-ci-002 — Nix flake for a reproducible dev + build environment](20260612-adr-ci-002.md) — **Accepted 2026-06-12** (relocated from main's **ADR-0049** on the same day — content unchanged — to keep CI/dev-env decisions out of `main`'s integer sequence). The single, version-pinned declaration of the **dev *and* build toolchain** so CI never relies on whatever Rust is on the build machine — mirroring **datamage ADR 0046**, but far simpler (pure-Rust TUI). Root **Nix flake** with two outputs: **`devShells.default`** (pinned **Rust 1.95.0** via `rust-toolchain.toml` + `rust-overlay`, `cargo-sweep`, and the musl cc for the static release build) and **`packages.default`** (`rustPlatform.buildRustPackage` from the committed `Cargo.lock`; `doCheck = false`). Exact-pin (not floating `stable`) so `nix flake update` can't surprise-bump clippy past the `-D warnings` gate. System inputs near-empty by design (`libsqlite3-sys bundled` → stdenv cc only; `arboard`→`x11rb` pure-Rust). `.envrc` (`use flake`) for direnv parity. Verified through the flake: `nix build` yields a working binary, clippy clean, **2424 tests pass / 0 fail / 1 intentional ignored doctest**. Consumed by **ADR-ci-001** (the pipeline). Alternatives rejected: dev-shell-only; a standard `rust:1.95` CI image (a second toolchain definition = drift); `rustup` on the build host (non-reproducible). +- [ADR-ci-003 — Cross-platform release builds (the D1 matrix)](20260613-adr-ci-003.md) — **Accepted 2026-06-13** (implemented + a real matrix release verified the same day — tag `v.0.0.0-citest3` published 8 assets). Cross-compiles the **four non-macOS D1 targets** from the Linux x86_64 runner with **`cargo-zigbuild`** (Zig's bundled clang + libc as one universal cross cc/linker, incl. rusqlite's bundled SQLite C; added to the flake devShell, replacing the single-target musl cc): **`x86_64`/`aarch64-unknown-linux-musl`** (musl + crt-static → fully static, **D2**) and **`x86_64-pc-windows-gnu`** / **`aarch64-pc-windows-gnullvm`** (Zig statically links libc → standalone `.exe`). **Windows `synchronization` stub:** Rust std links `-lsynchronization` (WaitOnAddress thread-parking), an import lib rust-overlay's toolchain doesn't ship and Zig's mingw lacks; the symbols are forwarded by `kernel32`, so an **empty 8-byte stub** `libsynchronization.a` (`ci/winstub/`, wired via `.cargo/config.toml` for the Windows targets only) satisfies the linker. **Workflow:** `release.yaml` = **`test` once (host) → `build` matrix** over the four targets (`needs: test`, `fail-fast: false`); each job packages binary (`.exe` for Windows) + `.sha256` and uploads to the **shared release** via idempotent create-or-get. **macOS deferred** — `arboard`→AppKit needs Apple's SDK, a licensing grey area on a Linux runner, and the **public** CI image can't carry it; its own step (osxcross + a private SDK, or a Mac runner). Alternatives rejected: `cross` (no macOS, needs DinD), per-target nix cross (Windows-aarch64 unpackaged, macOS-from-Linux unsupported), a real `libsynchronization.a` (more machinery, doesn't cover Windows-aarch64). Runtime-verified by the user (2026-06-13): Linux x86_64 + Windows aarch64 run correctly; Linux aarch64 + Windows x86_64 are the outstanding runtime checks. Builds on ADR-ci-002 (flake) and fills in ADR-ci-001 §3 (Release). From 2721bd8d043ebe83cf4fd3a608ebf3529f4870a3 Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Sun, 14 Jun 2026 21:11:28 +0000 Subject: [PATCH 14/22] =?UTF-8?q?ci:=20macOS=20(Tart)=20runner=20probe=20?= =?UTF-8?q?=E2=80=94=20throwaway=20diagnostic?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Manual-dispatch probe on runs-on macos:host to confirm the runner picks up jobs and report arch / macOS version / Xcode SDK / toolchains (nix, rustup, cargo) / git+node, before wiring the macOS release leg. Delete once done. --- .gitea/workflows/macos-probe.yaml | 38 +++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 .gitea/workflows/macos-probe.yaml diff --git a/.gitea/workflows/macos-probe.yaml b/.gitea/workflows/macos-probe.yaml new file mode 100644 index 0000000..352d87f --- /dev/null +++ b/.gitea/workflows/macos-probe.yaml @@ -0,0 +1,38 @@ +# THROWAWAY DIAGNOSTIC for the macOS (Tart) runner — delete once the macOS +# release leg is wired. Manual dispatch only: the Mac isn't always on, so this +# runs when you bring it up and trigger it from the Actions UI. +# +# Answers: does the `macos:host` runner pick up jobs, does it run on the host, +# what arch + macOS version, is the Xcode SDK present (needed to link arboard's +# AppKit), and which build toolchain is available (nix? rustup? bare cargo?) — +# plus git/node for actions/checkout. +name: macos-probe +on: [workflow_dispatch] + +jobs: + probe: + runs-on: "macos:host" + steps: + - name: identity, SDK, toolchains + run: | + echo "=== uname ==="; uname -a; echo "arch: $(uname -m)" + echo "=== macOS version ==="; sw_vers 2>&1 || echo "(sw_vers?)" + echo "=== host or container? ===" + if [ -f /.dockerenv ]; then echo "/.dockerenv PRESENT -> container"; else echo "host (no /.dockerenv)"; fi + echo "=== identity ==="; whoami; id + echo "" + echo "=== Xcode CLT / SDK (needed to link AppKit) ===" + xcode-select -p 2>&1 || echo "(no CLT)" + xcrun --show-sdk-path 2>&1 || echo "(no sdk)" + clang --version 2>&1 | head -1 || echo "(no clang)" + echo "" + echo "=== build toolchains on PATH ===" + echo "nix: $(command -v nix || echo NO)" + echo "rustup: $(command -v rustup || echo NO)" + echo "cargo: $(command -v cargo || echo NO)" + echo "rustc: $(command -v rustc || echo NO)" + rustc --version 2>/dev/null || true + echo "" + echo "=== git / node (for actions/checkout) ===" + echo "git: $(command -v git || echo NO) $(git --version 2>/dev/null)" + echo "node: $(command -v node || echo NO) $(node --version 2>/dev/null)" From 52815f1a76fd054e4541c94266303e3c22de53d9 Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Sun, 14 Jun 2026 21:18:25 +0000 Subject: [PATCH 15/22] ci: trigger macos-probe on push (workflow_dispatch needs default branch) Gitea only exposes workflow_dispatch for workflows on the default branch (main); our CI is on `ci`, so the manual-run button/API isn't available. Add a push trigger (filtered to the probe file) so we can drive the macOS runner test from the ci branch. workflow_dispatch kept for post-merge. --- .gitea/workflows/macos-probe.yaml | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/.gitea/workflows/macos-probe.yaml b/.gitea/workflows/macos-probe.yaml index 352d87f..93cc630 100644 --- a/.gitea/workflows/macos-probe.yaml +++ b/.gitea/workflows/macos-probe.yaml @@ -1,13 +1,21 @@ # THROWAWAY DIAGNOSTIC for the macOS (Tart) runner — delete once the macOS -# release leg is wired. Manual dispatch only: the Mac isn't always on, so this -# runs when you bring it up and trigger it from the Actions UI. +# release leg is wired. +# +# Trigger: runs on a push that touches THIS file. workflow_dispatch is kept too, +# but Gitea only exposes manual dispatch for workflows on the DEFAULT branch +# (main) — and all our CI is still on `ci` — so push is how we drive it for now. +# Bring the Mac up before pushing a change here so the run isn't left queued. # # Answers: does the `macos:host` runner pick up jobs, does it run on the host, # what arch + macOS version, is the Xcode SDK present (needed to link arboard's # AppKit), and which build toolchain is available (nix? rustup? bare cargo?) — # plus git/node for actions/checkout. name: macos-probe -on: [workflow_dispatch] +on: + push: + paths: + - '.gitea/workflows/macos-probe.yaml' + workflow_dispatch: jobs: probe: From 0878c6df19b83ce5a5cae93e49ea6e2646433c6f Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Sun, 14 Jun 2026 21:21:13 +0000 Subject: [PATCH 16/22] ci: target the macOS runner by label name `macos` (not `macos:host`) In act_runner a label is `:`; `:host` is the execution- backend schema (run on host, no container), not part of the label. The runner registered as `macos:host` therefore has the label `macos`, which is what runs-on must reference. --- .gitea/workflows/macos-probe.yaml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.gitea/workflows/macos-probe.yaml b/.gitea/workflows/macos-probe.yaml index 93cc630..a367f35 100644 --- a/.gitea/workflows/macos-probe.yaml +++ b/.gitea/workflows/macos-probe.yaml @@ -19,7 +19,10 @@ on: jobs: probe: - runs-on: "macos:host" + # Label NAME only. The runner was registered as `macos:host`, but `:host` is + # act_runner's execution-backend schema (run on the host, no container), not + # part of the label — so the label is just `macos`. + runs-on: macos steps: - name: identity, SDK, toolchains run: | From d5fb47bcc85b98c887b32af912c5de763d68d1e8 Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Sun, 14 Jun 2026 21:28:41 +0000 Subject: [PATCH 17/22] ci: macOS build smoke-test + flake darwin support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the two *-apple-darwin targets to rust-toolchain.toml and apple-sdk + libiconv to the flake devShell (darwin only) so the nix toolchain links AppKit; make cargo-zigbuild/zig Linux-only (macOS builds natively). Repoint the throwaway macOS workflow to actually build both darwin targets through the flake on the Tart runner — the first real check of the macOS leg, which can't be verified locally. Delete once release-macos lands. --- .gitea/workflows/macos-probe.yaml | 66 +++++++++++++------------------ flake.nix | 23 +++++++---- rust-toolchain.toml | 5 +++ 3 files changed, 48 insertions(+), 46 deletions(-) diff --git a/.gitea/workflows/macos-probe.yaml b/.gitea/workflows/macos-probe.yaml index a367f35..58d31bd 100644 --- a/.gitea/workflows/macos-probe.yaml +++ b/.gitea/workflows/macos-probe.yaml @@ -1,49 +1,39 @@ -# THROWAWAY DIAGNOSTIC for the macOS (Tart) runner — delete once the macOS -# release leg is wired. +# THROWAWAY build smoke-test for the macOS (Tart) runner. Verifies both +# *-apple-darwin targets actually compile and link (incl. arboard's AppKit) +# through the flake on the real Mac, before the full release-macos workflow is +# wired. Delete once that lands. # -# Trigger: runs on a push that touches THIS file. workflow_dispatch is kept too, -# but Gitea only exposes manual dispatch for workflows on the DEFAULT branch -# (main) — and all our CI is still on `ci` — so push is how we drive it for now. -# Bring the Mac up before pushing a change here so the run isn't left queued. -# -# Answers: does the `macos:host` runner pick up jobs, does it run on the host, -# what arch + macOS version, is the Xcode SDK present (needed to link arboard's -# AppKit), and which build toolchain is available (nix? rustup? bare cargo?) — -# plus git/node for actions/checkout. -name: macos-probe +# Push-triggered (workflow_dispatch only works for workflows on the default +# branch; our CI is on `ci`). Runs when the flake/toolchain or this file change. +# Bring the Mac up before pushing so the run isn't left queued. +name: macos-build-test on: push: paths: - '.gitea/workflows/macos-probe.yaml' + - 'flake.nix' + - 'rust-toolchain.toml' workflow_dispatch: jobs: - probe: - # Label NAME only. The runner was registered as `macos:host`, but `:host` is - # act_runner's execution-backend schema (run on the host, no container), not - # part of the label — so the label is just `macos`. + build: + # Label NAME only — `:host` in the runner registration is the execution + # backend (run on host), not part of the label. runs-on: macos + env: + # Guarantee flakes regardless of the Mac's nix config. + NIX_CONFIG: "experimental-features = nix-command flakes" steps: - - name: identity, SDK, toolchains + - uses: actions/checkout@v4 + - name: build both darwin targets through the flake run: | - echo "=== uname ==="; uname -a; echo "arch: $(uname -m)" - echo "=== macOS version ==="; sw_vers 2>&1 || echo "(sw_vers?)" - echo "=== host or container? ===" - if [ -f /.dockerenv ]; then echo "/.dockerenv PRESENT -> container"; else echo "host (no /.dockerenv)"; fi - echo "=== identity ==="; whoami; id - echo "" - echo "=== Xcode CLT / SDK (needed to link AppKit) ===" - xcode-select -p 2>&1 || echo "(no CLT)" - xcrun --show-sdk-path 2>&1 || echo "(no sdk)" - clang --version 2>&1 | head -1 || echo "(no clang)" - echo "" - echo "=== build toolchains on PATH ===" - echo "nix: $(command -v nix || echo NO)" - echo "rustup: $(command -v rustup || echo NO)" - echo "cargo: $(command -v cargo || echo NO)" - echo "rustc: $(command -v rustc || echo NO)" - rustc --version 2>/dev/null || true - echo "" - echo "=== git / node (for actions/checkout) ===" - echo "git: $(command -v git || echo NO) $(git --version 2>/dev/null)" - echo "node: $(command -v node || echo NO) $(node --version 2>/dev/null)" + set -e + for t in aarch64-apple-darwin x86_64-apple-darwin; do + echo "==================== $t ====================" + nix develop -c cargo build --release --target "$t" + f="target/$t/release/rdbms-playground" + file "$f" + echo "--- linked libs (otool -L) ---" + otool -L "$f" 2>/dev/null | head -8 || true + done + echo "=== both darwin targets built ===" diff --git a/flake.nix b/flake.nix index 6407308..c0b5bb6 100644 --- a/flake.nix +++ b/flake.nix @@ -60,7 +60,15 @@ packages.rdbms-playground = rdbms-playground; devShells.default = pkgs.mkShell { - inherit buildInputs; + buildInputs = buildInputs ++ pkgs.lib.optionals pkgs.stdenv.isDarwin [ + # macOS release builds (aarch64/x86_64-apple-darwin) link AppKit + # (arboard) + libSystem; the nix toolchain's own clang resolves the + # frameworks via the Apple SDK provided here. (The Mac runner also + # has full Xcode, but the devShell stays self-contained.) `libiconv` + # is linked by several crates on darwin. + pkgs.apple-sdk + pkgs.libiconv + ]; nativeBuildInputs = nativeBuildInputs ++ [ rust # Dev-disk maintenance: cargo never garbage-collects stale per-hash @@ -68,12 +76,12 @@ # CLAUDE.md "Build hygiene"). cargo-sweep prunes them; run it # periodically between milestones. pkgs.cargo-sweep - # Cross-compilation for the D1 release matrix. `cargo zigbuild` uses - # Zig's bundled clang + libc as one universal cross cc/linker for - # every non-macOS target (Linux musl x64/arm64, Windows gnu/gnullvm - # x64/arm64) — including the `cc`-crate compile of rusqlite's bundled - # SQLite C — with no per-target toolchain or SDK. It auto-discovers - # `zig` on PATH, so no extra env is needed. + ] ++ pkgs.lib.optionals pkgs.stdenv.isLinux [ + # Cross-compilation for the non-macOS D1 targets: `cargo zigbuild` + # uses Zig's bundled clang + libc as one universal cross cc/linker + # (incl. the `cc`-crate compile of rusqlite's bundled SQLite C) for + # Linux musl + Windows gnu/gnullvm. macOS builds natively with the + # Apple toolchain on the Mac runner, so these are Linux-only. pkgs.cargo-zigbuild pkgs.zig ]; @@ -82,7 +90,6 @@ echo "RDBMS Playground dev shell ($(uname -s))" echo " rust: $(rustc --version | cut -d' ' -f1-2)" echo " cargo: $(cargo --version | cut -d' ' -f1-2)" - echo " zig: $(zig version 2>/dev/null || echo '?') (cargo-zigbuild cross targets)" ''; }; }); diff --git a/rust-toolchain.toml b/rust-toolchain.toml index 64a9490..e3d51b3 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -18,4 +18,9 @@ targets = [ "aarch64-unknown-linux-musl", "x86_64-pc-windows-gnu", "aarch64-pc-windows-gnullvm", + # macOS — built natively on the Apple-Silicon Mac runner (aarch64 native, + # x86_64 cross). These need Apple's SDK to link, which a Linux runner can't + # supply, so they are produced only on the Mac (see docs/ci/adr ADR-ci-003). + "aarch64-apple-darwin", + "x86_64-apple-darwin", ] From 4d004f5847a76e32e442790687985d7860b364ac Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Sun, 14 Jun 2026 21:36:08 +0000 Subject: [PATCH 18/22] ci: drop nix libiconv from darwin build (portable macOS binary) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The smoke-test caught the aarch64 binary linking a /nix/store libiconv.dylib — non-portable (won't exist on a user's Mac). The Apple SDK already provides a system libiconv stub, so removing pkgs.libiconv makes the linker resolve -liconv to /usr/lib instead. The smoke-test now fails if any /nix/store dylib is linked. --- .gitea/workflows/macos-probe.yaml | 10 ++++++++-- flake.nix | 11 ++++++----- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/.gitea/workflows/macos-probe.yaml b/.gitea/workflows/macos-probe.yaml index 58d31bd..221b922 100644 --- a/.gitea/workflows/macos-probe.yaml +++ b/.gitea/workflows/macos-probe.yaml @@ -34,6 +34,12 @@ jobs: f="target/$t/release/rdbms-playground" file "$f" echo "--- linked libs (otool -L) ---" - otool -L "$f" 2>/dev/null | head -8 || true + otool -L "$f" + # Portability guard: a distributable macOS binary must link only + # system libs (/usr/lib, /System/Library) — never a /nix/store path. + if otool -L "$f" | grep -q /nix/store; then + echo "ERROR: $t binary links a /nix/store dylib — not portable"; exit 1 + fi + echo "OK: $t links only system libraries" done - echo "=== both darwin targets built ===" + echo "=== both darwin targets built + portable ===" diff --git a/flake.nix b/flake.nix index c0b5bb6..741fa2b 100644 --- a/flake.nix +++ b/flake.nix @@ -62,12 +62,13 @@ devShells.default = pkgs.mkShell { buildInputs = buildInputs ++ pkgs.lib.optionals pkgs.stdenv.isDarwin [ # macOS release builds (aarch64/x86_64-apple-darwin) link AppKit - # (arboard) + libSystem; the nix toolchain's own clang resolves the - # frameworks via the Apple SDK provided here. (The Mac runner also - # has full Xcode, but the devShell stays self-contained.) `libiconv` - # is linked by several crates on darwin. + # (arboard) + libSystem; the Apple SDK provides those framework/ + # system-lib stubs as *system* paths (/usr/lib, /System/Library), so + # the resulting binary is portable. NOTE: do NOT add `pkgs.libiconv` + # — it makes the linker prefer the nix-store libiconv.dylib, baking a + # /nix/store path into the binary (non-portable). The SDK's own + # libiconv stub resolves `-liconv` to /usr/lib/libiconv instead. pkgs.apple-sdk - pkgs.libiconv ]; nativeBuildInputs = nativeBuildInputs ++ [ rust From 9a126782f17abdd4b34b4f1d1357212af3eb56fa Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Sun, 14 Jun 2026 21:43:01 +0000 Subject: [PATCH 19/22] ci: de-nix macOS binary libiconv via install_name_tool + re-sign libiconv is the only /nix/store dep the darwin stdenv bakes in (everything else is system frameworks + libSystem/libobjc). The smoke-test now rewrites that load path to /usr/lib/libiconv.2.dylib (ABI-compatible, present on every Mac), re-signs ad-hoc (install_name_tool breaks the sig; arm64 requires a valid one), then verifies no /nix/store paths remain, the signature is valid, and the native binary launches. Flake comment updated to reflect the propagated-libiconv reality. --- .gitea/workflows/macos-probe.yaml | 33 ++++++++++++++++++++++--------- flake.nix | 11 ++++++----- 2 files changed, 30 insertions(+), 14 deletions(-) diff --git a/.gitea/workflows/macos-probe.yaml b/.gitea/workflows/macos-probe.yaml index 221b922..d338026 100644 --- a/.gitea/workflows/macos-probe.yaml +++ b/.gitea/workflows/macos-probe.yaml @@ -25,21 +25,36 @@ jobs: NIX_CONFIG: "experimental-features = nix-command flakes" steps: - uses: actions/checkout@v4 - - name: build both darwin targets through the flake + - name: build, de-nix, sign, verify both darwin targets run: | set -e for t in aarch64-apple-darwin x86_64-apple-darwin; do echo "==================== $t ====================" nix develop -c cargo build --release --target "$t" f="target/$t/release/rdbms-playground" - file "$f" - echo "--- linked libs (otool -L) ---" - otool -L "$f" - # Portability guard: a distributable macOS binary must link only - # system libs (/usr/lib, /System/Library) — never a /nix/store path. + + # The darwin stdenv bakes a /nix/store libiconv load path into the + # binary. Rewrite it to the system libiconv (every Mac has it, ABI- + # compatible), then re-sign ad-hoc — install_name_tool invalidates + # the signature and arm64 won't run an unsigned/broken-sig binary. + for l in $(otool -L "$f" | awk '/\/nix\/store.*libiconv.*dylib/ {print $1}'); do + echo "rewrite $l -> /usr/lib/libiconv.2.dylib" + install_name_tool -change "$l" /usr/lib/libiconv.2.dylib "$f" + done + codesign --force --sign - "$f" + + echo "--- linked libs ---"; otool -L "$f" if otool -L "$f" | grep -q /nix/store; then - echo "ERROR: $t binary links a /nix/store dylib — not portable"; exit 1 + echo "ERROR: $t still links a /nix/store dylib"; exit 1 fi - echo "OK: $t links only system libraries" + codesign --verify --verbose=2 "$f" && echo "signature OK" + + # Smoke-run the natively-runnable target (this VM is arm64). + if [ "$t" = "aarch64-apple-darwin" ]; then + echo "--- run --help ---"; "$f" --help | head -1 + else + echo "(skip run: $t needs Rosetta)" + fi + echo "OK: $t portable" done - echo "=== both darwin targets built + portable ===" + echo "=== both darwin targets built, de-nixed, signed, verified ===" diff --git a/flake.nix b/flake.nix index 741fa2b..cf5b968 100644 --- a/flake.nix +++ b/flake.nix @@ -63,11 +63,12 @@ buildInputs = buildInputs ++ pkgs.lib.optionals pkgs.stdenv.isDarwin [ # macOS release builds (aarch64/x86_64-apple-darwin) link AppKit # (arboard) + libSystem; the Apple SDK provides those framework/ - # system-lib stubs as *system* paths (/usr/lib, /System/Library), so - # the resulting binary is portable. NOTE: do NOT add `pkgs.libiconv` - # — it makes the linker prefer the nix-store libiconv.dylib, baking a - # /nix/store path into the binary (non-portable). The SDK's own - # libiconv stub resolves `-liconv` to /usr/lib/libiconv instead. + # system-lib stubs as *system* paths (/usr/lib, /System/Library). + # NOTE: the darwin stdenv still propagates a *nix-store* libiconv and + # links it regardless of inputs, so the release workflow rewrites that + # one load path to /usr/lib/libiconv.2.dylib (install_name_tool) and + # re-signs — see release-macos / the macOS smoke-test. Adding + # `pkgs.libiconv` here would only reinforce the wrong path, so don't. pkgs.apple-sdk ]; nativeBuildInputs = nativeBuildInputs ++ [ From 60dbb903cc87f90c42cab9db7b8724f373606c35 Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Sun, 14 Jun 2026 22:07:48 +0000 Subject: [PATCH 20/22] =?UTF-8?q?ci:=20macOS=20smoke-test=20=E2=80=94=20ru?= =?UTF-8?q?n=20tests=20+=20nix-store=20generation=20pruning?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add `cargo test` before the darwin builds (gate is Linux-only; the macOS leg is test-then-build) — a full dry-run of release-macos bar the upload. - Add an `if: always()` prune step. The runner wipes the workspace each run, so cargo target/ never accumulates (no sweep). The persistent cache is the nix store: record the current toolchain in a persistent profile, keep the 2 newest generations (nix-env --delete-generations +2), reclaim the rest (nix-collect-garbage). Pairs with min-free/max-free in the runner nix.conf. --- .gitea/workflows/macos-probe.yaml | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/.gitea/workflows/macos-probe.yaml b/.gitea/workflows/macos-probe.yaml index d338026..565625a 100644 --- a/.gitea/workflows/macos-probe.yaml +++ b/.gitea/workflows/macos-probe.yaml @@ -25,6 +25,8 @@ jobs: NIX_CONFIG: "experimental-features = nix-command flakes" steps: - uses: actions/checkout@v4 + - name: test (macOS — the gate only covers Linux) + run: nix develop -c cargo test --no-fail-fast - name: build, de-nix, sign, verify both darwin targets run: | set -e @@ -58,3 +60,22 @@ jobs: echo "OK: $t portable" done echo "=== both darwin targets built, de-nixed, signed, verified ===" + + - name: prune nix store — keep the last 2 toolchain generations + # The runner wipes the whole workspace before each run, so cargo target/ + # never accumulates (no sweep needed). The persistent caches are the nix + # store (/nix) and ~/.cargo (in $HOME). Bound the nix store by generation: + # record the current devShell closure as a generation of a persistent + # profile (lives in $HOME, survives the workspace wipe), keep the 2 newest + # (current + previous), reclaim what the older ones referenced. No time + # window — never more than two toolchains regardless of flake.lock churn. + if: always() + run: | + echo "--- disk before ---"; df -h / | tail -1 + P="$HOME/.cache/rdbms-ci/toolchain" + nix develop --profile "$P" -c true || true + nix-env -p "$P" --delete-generations +2 || true + nix-collect-garbage || true + echo "--- disk after ---"; df -h / | tail -1 + # ~/.cargo/registry also persists but grows only on Cargo.lock bumps; + # bound it later with `cargo-cache --autoclean` if it ever matters. From 309d2e0b3ff82ff073c2f37ae87309d3c6d60dad Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Sun, 14 Jun 2026 22:18:02 +0000 Subject: [PATCH 21/22] ci: release-macos workflow (dispatch); retire macOS smoke-test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The macOS release leg: workflow_dispatch (tag input) on the Tart runner — test → build both *-apple-darwin targets → rewrite nix libiconv to /usr/lib + ad-hoc re-sign → upload binary + .sha256 to the tagged release (idempotent create-or-get) → prune the nix store by generation. Composed entirely of parts the smoke-test proved green, so the smoke-test is removed. Dispatch-only fits the intermittent runner and keeps the 4-target Linux/ Windows release independent. Becomes triggerable once CI is on the default branch (workflow_dispatch is default-branch-only in Gitea). --- .gitea/workflows/macos-probe.yaml | 81 ------------------------ .gitea/workflows/release-macos.yaml | 95 +++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+), 81 deletions(-) delete mode 100644 .gitea/workflows/macos-probe.yaml create mode 100644 .gitea/workflows/release-macos.yaml diff --git a/.gitea/workflows/macos-probe.yaml b/.gitea/workflows/macos-probe.yaml deleted file mode 100644 index 565625a..0000000 --- a/.gitea/workflows/macos-probe.yaml +++ /dev/null @@ -1,81 +0,0 @@ -# THROWAWAY build smoke-test for the macOS (Tart) runner. Verifies both -# *-apple-darwin targets actually compile and link (incl. arboard's AppKit) -# through the flake on the real Mac, before the full release-macos workflow is -# wired. Delete once that lands. -# -# Push-triggered (workflow_dispatch only works for workflows on the default -# branch; our CI is on `ci`). Runs when the flake/toolchain or this file change. -# Bring the Mac up before pushing so the run isn't left queued. -name: macos-build-test -on: - push: - paths: - - '.gitea/workflows/macos-probe.yaml' - - 'flake.nix' - - 'rust-toolchain.toml' - workflow_dispatch: - -jobs: - build: - # Label NAME only — `:host` in the runner registration is the execution - # backend (run on host), not part of the label. - runs-on: macos - env: - # Guarantee flakes regardless of the Mac's nix config. - NIX_CONFIG: "experimental-features = nix-command flakes" - steps: - - uses: actions/checkout@v4 - - name: test (macOS — the gate only covers Linux) - run: nix develop -c cargo test --no-fail-fast - - name: build, de-nix, sign, verify both darwin targets - run: | - set -e - for t in aarch64-apple-darwin x86_64-apple-darwin; do - echo "==================== $t ====================" - nix develop -c cargo build --release --target "$t" - f="target/$t/release/rdbms-playground" - - # The darwin stdenv bakes a /nix/store libiconv load path into the - # binary. Rewrite it to the system libiconv (every Mac has it, ABI- - # compatible), then re-sign ad-hoc — install_name_tool invalidates - # the signature and arm64 won't run an unsigned/broken-sig binary. - for l in $(otool -L "$f" | awk '/\/nix\/store.*libiconv.*dylib/ {print $1}'); do - echo "rewrite $l -> /usr/lib/libiconv.2.dylib" - install_name_tool -change "$l" /usr/lib/libiconv.2.dylib "$f" - done - codesign --force --sign - "$f" - - echo "--- linked libs ---"; otool -L "$f" - if otool -L "$f" | grep -q /nix/store; then - echo "ERROR: $t still links a /nix/store dylib"; exit 1 - fi - codesign --verify --verbose=2 "$f" && echo "signature OK" - - # Smoke-run the natively-runnable target (this VM is arm64). - if [ "$t" = "aarch64-apple-darwin" ]; then - echo "--- run --help ---"; "$f" --help | head -1 - else - echo "(skip run: $t needs Rosetta)" - fi - echo "OK: $t portable" - done - echo "=== both darwin targets built, de-nixed, signed, verified ===" - - - name: prune nix store — keep the last 2 toolchain generations - # The runner wipes the whole workspace before each run, so cargo target/ - # never accumulates (no sweep needed). The persistent caches are the nix - # store (/nix) and ~/.cargo (in $HOME). Bound the nix store by generation: - # record the current devShell closure as a generation of a persistent - # profile (lives in $HOME, survives the workspace wipe), keep the 2 newest - # (current + previous), reclaim what the older ones referenced. No time - # window — never more than two toolchains regardless of flake.lock churn. - if: always() - run: | - echo "--- disk before ---"; df -h / | tail -1 - P="$HOME/.cache/rdbms-ci/toolchain" - nix develop --profile "$P" -c true || true - nix-env -p "$P" --delete-generations +2 || true - nix-collect-garbage || true - echo "--- disk after ---"; df -h / | tail -1 - # ~/.cargo/registry also persists but grows only on Cargo.lock bumps; - # bound it later with `cargo-cache --autoclean` if it ever matters. diff --git a/.gitea/workflows/release-macos.yaml b/.gitea/workflows/release-macos.yaml new file mode 100644 index 0000000..8f75829 --- /dev/null +++ b/.gitea/workflows/release-macos.yaml @@ -0,0 +1,95 @@ +# macOS release leg — the two *-apple-darwin binaries, built natively on the +# Tart (Apple-Silicon) runner and attached to an existing Gitea release. +# +# Manual dispatch only: the Mac runner is intermittent, so this is triggered by +# hand (with the Mac up) for a given release tag. The 4-target Linux/Windows +# release (release.yaml) runs on the tag itself and never waits on the Mac, so a +# release always has those four; the macOS two are added by dispatching this. +# +# NOTE: Gitea exposes workflow_dispatch only for workflows on the DEFAULT branch, +# so this becomes triggerable once the CI work is merged to `main`. +name: release-macos +on: + workflow_dispatch: + inputs: + tag: + description: 'Release tag to build the macOS binaries for and attach to (e.g. v0.1.0)' + required: true + +jobs: + release-macos: + runs-on: macos + env: + NIX_CONFIG: "experimental-features = nix-command flakes" + TAG: ${{ inputs.tag }} + # Auto-provided by Gitea Actions; has repo write (release) scope. + TOKEN: ${{ secrets.GITEA_TOKEN }} + API: ${{ github.server_url }}/api/v1 + REPO: ${{ github.repository }} + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.tag }} + + - name: test + run: nix develop -c cargo test --no-fail-fast + + - name: build, de-nix, sign, package + publish + run: | + set -e + mkdir -p dist + for t in aarch64-apple-darwin x86_64-apple-darwin; do + echo "==================== $t ====================" + nix develop -c cargo build --release --target "$t" + f="target/$t/release/rdbms-playground" + + # Rewrite the nix-store libiconv load path to the system one, then + # re-sign ad-hoc (install_name_tool invalidates the signature; arm64 + # requires a valid one). Guard against any remaining /nix/store dep. + for l in $(otool -L "$f" | awk '/\/nix\/store.*libiconv.*dylib/ {print $1}'); do + install_name_tool -change "$l" /usr/lib/libiconv.2.dylib "$f" + done + codesign --force --sign - "$f" + if otool -L "$f" | grep -q /nix/store; then + echo "ERROR: $t binary links a /nix/store dylib"; exit 1 + fi + + out="rdbms-playground-$TAG-$t" + cp "$f" "dist/$out" + ( cd dist && shasum -a 256 "$out" > "$out.sha256" ) # macOS: shasum, not sha256sum + done + ls -l dist + + # Idempotent create-or-get the release (release.yaml likely created it + # already from the tag), then upload the two macOS binaries + checksums. + created=$(curl -sS -X POST "$API/repos/$REPO/releases" \ + -H "Authorization: token $TOKEN" -H "Content-Type: application/json" \ + -d "{\"tag_name\":\"$TAG\",\"name\":\"$TAG\",\"body\":\"Automated release for $TAG.\"}") + id=$(printf '%s' "$created" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{try{const o=JSON.parse(s);process.stdout.write(String(o.id||""))}catch(e){}})') + if [ -z "$id" ]; then + id=$(curl -sS "$API/repos/$REPO/releases/tags/$TAG" \ + -H "Authorization: token $TOKEN" \ + | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{process.stdout.write(String(JSON.parse(s).id))})') + fi + echo "release id: $id" + for fa in dist/*; do + name=$(basename "$fa") + echo "uploading $name" + curl -sS -X POST "$API/repos/$REPO/releases/$id/assets?name=$name" \ + -H "Authorization: token $TOKEN" -F "attachment=@$fa" > /dev/null + done + echo "published macOS assets for $TAG" + + - name: prune nix store — keep the last 2 toolchain generations + # The runner wipes the workspace each run, so cargo target/ never + # accumulates. Bound the persistent nix store by generation: record the + # current devShell as a generation of a persistent profile (in $HOME), + # keep the 2 newest, reclaim what older ones referenced. + if: always() + run: | + echo "--- disk before ---"; df -h / | tail -1 + P="$HOME/.cache/rdbms-ci/toolchain" + nix develop --profile "$P" -c true || true + nix-env -p "$P" --delete-generations +2 || true + nix-collect-garbage || true + echo "--- disk after ---"; df -h / | tail -1 From aeb92f56a78aa0bd475272327552f270736b0d01 Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Mon, 15 Jun 2026 15:56:38 +0000 Subject: [PATCH 22/22] docs(ci): record macOS implementation in ADR-ci-003 (D1 complete) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit macOS is no longer deferred — built natively on a Tart (Apple-Silicon) runner (real hardware → licensed SDK, no grey area). Amendment documents release-macos.yaml (dispatch-only, needs main), the libiconv de-nix + ad-hoc re-sign, the runner-label `:host` backend nuance, generation-based cache pruning, and D2-on-macOS (system libs only). All six D1 targets now produce artifacts. Updates the deferred list + index entry. --- docs/ci/adr/20260613-adr-ci-003.md | 46 +++++++++++++++++++++++++++++- docs/ci/adr/README.md | 2 +- 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/docs/ci/adr/20260613-adr-ci-003.md b/docs/ci/adr/20260613-adr-ci-003.md index f840fc3..882e9d8 100644 --- a/docs/ci/adr/20260613-adr-ci-003.md +++ b/docs/ci/adr/20260613-adr-ci-003.md @@ -20,6 +20,49 @@ This ADR records the **cross-platform build strategy**; it sits on top of **ADR-ci-002** (the nix flake, which now carries the cross toolchain) and **ADR-ci-001** (the pipeline, whose release job this fills in). +## Amendment — 2026-06-14: macOS implemented (closes D1) + +macOS is no longer deferred. The two `*-apple-darwin` targets now build on a +**Tart (Apple-Silicon) macOS runner** registered to Gitea — building on **real +Apple hardware** makes the SDK fully licensed, so the whole osxcross / SDK +grey-area + public-image-redistribution problem (§5 below) simply **does not +arise**. With all six D1 targets producing artifacts, **D1 is complete.** + +Details, all verified on the runner via a throwaway smoke-test before wiring the +release leg: + +- **`release-macos.yaml`** — `workflow_dispatch` with a `tag` input, + `runs-on: macos`. The runner registered as `macos:host`, but `:host` is + act_runner's execution-backend schema (run on host, no container), **not** part + of the label, so the label is `macos`. Steps: `cargo test` (macOS gets the only + automated test coverage outside the Linux gate — user choice) → build both + darwin targets natively through the flake (`apple-sdk` added to the devShell so + the toolchain links AppKit) → **upload to the same release** via the idempotent + create-or-get. +- **De-nix + re-sign.** The darwin stdenv bakes a `/nix/store` `libiconv` load + path into the binary (the *only* non-system dependency; everything else is + AppKit/Foundation/CoreGraphics/IOKit + `libSystem`/`libobjc`). The release step + rewrites it to `/usr/lib/libiconv.2.dylib` with `install_name_tool` and + **re-signs ad-hoc** (`codesign -f -s -`) — `install_name_tool` invalidates the + signature and Apple Silicon refuses an unsigned binary. A guard fails the build + if any `/nix/store` path remains. Result: portable, signed binaries (the native + one was confirmed to launch). +- **Dispatch-only, intermittent runner.** The Mac isn't always on, so macOS is a + separate dispatched workflow (not a job in `release.yaml`) — a release always + carries the four Linux/Windows assets regardless of the Mac, and the two macOS + assets are added by dispatching `release-macos` for that tag. **Caveat:** Gitea + exposes `workflow_dispatch` only for workflows on the **default branch**, so + `release-macos` becomes triggerable once the CI work is merged to `main`. +- **Cache hygiene (host-execution runner).** The runner wipes the workspace each + run, so cargo `target/` never accumulates; the persistent cache is the nix + store, bounded by **generation** — record the current devShell in a persistent + profile, keep the 2 newest generations (`nix-env --delete-generations +2`), + reclaim the rest. (The first sweep reclaimed a ~3.8 GB one-time backlog of + build scaffolding — source + build-only deps, not re-installed toolchains.) +- **D2 on macOS.** macOS binaries cannot be fully static (`libSystem` is always + dynamic); "no runtime deps" there means *system libraries only*, which the + de-nix step guarantees. + ## Context `requirements.md` **D1** asks for binaries on **Linux, macOS, Windows × x86_64 @@ -135,7 +178,8 @@ user decides when we get there. ## Deferred / out of scope -- **macOS** (x86_64 + aarch64) — the SDK/runner decision above. +- ~~**macOS** (x86_64 + aarch64)~~ — **done** via the Tart runner (see the + 2026-06-14 amendment); §5 below is the as-deferred rationale, kept for history. - **D3 packaging** — Homebrew / Scoop / winget / `cargo-binstall` manifests (and binstall-friendly archive naming). - **CI speed** — caching per-target builds / Zig's libc cache. diff --git a/docs/ci/adr/README.md b/docs/ci/adr/README.md index 74a2223..d2a5e66 100644 --- a/docs/ci/adr/README.md +++ b/docs/ci/adr/README.md @@ -20,4 +20,4 @@ here too). - [ADR-ci-001 — CI + release pipeline on Gitea Actions](20260612-adr-ci-001.md) — **Accepted 2026-06-12** (implemented the same day on the `ci` branch). Establishes the CI/release pipeline on the self-hosted Gitea instance's Actions runner (`ci-public`). **Runner model** (established by a throwaway probe): jobs execute *inside* a container (`catthehacker/ubuntu:act-22.04` by default), as root, so the runner host's nix is **not** reachable from steps. **Toolchain delivery:** a **baked CI image** — `node:22-bookworm-slim` (satisfies the act_runner job-container contract: `/bin/sleep` keep-alive, `bash`, `node` for JS actions; a bare `nixos/nix` image lacks these and won't start) **+ single-user nix + the flake's devShell pre-warmed** — built by `build-ci-image.yaml` via DinD and pushed to the Gitea container registry as a **public** package, so CI runs `nix develop -c …` against the **same pinned toolchain as dev** (the flake, ADR-ci-002) with a warm store (~1.4 s to a ready toolchain). **Gate** (`ci.yaml`): `clippy -D warnings` + `cargo test` inside that image on branch pushes + PRs; **fmt deliberately not gated** (the tree isn't stock-rustfmt-clean — user decision, revisit on `main`; see ADR-ci-002). **Release** (`release.yaml`): on a `v*` tag, runs the tests, builds the **static `x86_64-unknown-linux-musl` binary** (D2: single static binary, no runtime deps — the glibc/nix build is non-portable), strips it, and publishes it + a `.sha256` to a Gitea release via the API and the auto-provided `GITEA_TOKEN`. **Triggers:** gate + image-build are scoped to **branch** pushes (`branches: ['**']`) so a release tag doesn't spuriously re-run them; the image-build additionally path-filters to its inputs (Dockerfile/flake/toolchain); the release owns tags. **Auth:** a dedicated PAT (`REGISTRY_USERNAME`/`REGISTRY_TOKEN` secrets) pushes the image; the auto `GITEA_TOKEN` publishes releases. **Scope:** the original release job was Linux x86_64 only; it's now the **four non-macOS D1 targets** (Linux + Windows × x86_64/aarch64) cross-built via cargo-zigbuild — see **ADR-ci-003**. macOS, D3 package-manager manifests, CI-speed dependency caching, and the website's static→Cloudflare deploy remain deferred, added step by step. Verified live: probe → runner facts; image built + checked locally; gate green (**2424 tests**); release exercised end-to-end (`v0.0.0-citest2` published with binary + checksum). Builds on **ADR-ci-002** (the nix flake, relocated here from main's ADR-0049 to avoid exactly this cross-branch collision). - [ADR-ci-002 — Nix flake for a reproducible dev + build environment](20260612-adr-ci-002.md) — **Accepted 2026-06-12** (relocated from main's **ADR-0049** on the same day — content unchanged — to keep CI/dev-env decisions out of `main`'s integer sequence). The single, version-pinned declaration of the **dev *and* build toolchain** so CI never relies on whatever Rust is on the build machine — mirroring **datamage ADR 0046**, but far simpler (pure-Rust TUI). Root **Nix flake** with two outputs: **`devShells.default`** (pinned **Rust 1.95.0** via `rust-toolchain.toml` + `rust-overlay`, `cargo-sweep`, and the musl cc for the static release build) and **`packages.default`** (`rustPlatform.buildRustPackage` from the committed `Cargo.lock`; `doCheck = false`). Exact-pin (not floating `stable`) so `nix flake update` can't surprise-bump clippy past the `-D warnings` gate. System inputs near-empty by design (`libsqlite3-sys bundled` → stdenv cc only; `arboard`→`x11rb` pure-Rust). `.envrc` (`use flake`) for direnv parity. Verified through the flake: `nix build` yields a working binary, clippy clean, **2424 tests pass / 0 fail / 1 intentional ignored doctest**. Consumed by **ADR-ci-001** (the pipeline). Alternatives rejected: dev-shell-only; a standard `rust:1.95` CI image (a second toolchain definition = drift); `rustup` on the build host (non-reproducible). -- [ADR-ci-003 — Cross-platform release builds (the D1 matrix)](20260613-adr-ci-003.md) — **Accepted 2026-06-13** (implemented + a real matrix release verified the same day — tag `v.0.0.0-citest3` published 8 assets). Cross-compiles the **four non-macOS D1 targets** from the Linux x86_64 runner with **`cargo-zigbuild`** (Zig's bundled clang + libc as one universal cross cc/linker, incl. rusqlite's bundled SQLite C; added to the flake devShell, replacing the single-target musl cc): **`x86_64`/`aarch64-unknown-linux-musl`** (musl + crt-static → fully static, **D2**) and **`x86_64-pc-windows-gnu`** / **`aarch64-pc-windows-gnullvm`** (Zig statically links libc → standalone `.exe`). **Windows `synchronization` stub:** Rust std links `-lsynchronization` (WaitOnAddress thread-parking), an import lib rust-overlay's toolchain doesn't ship and Zig's mingw lacks; the symbols are forwarded by `kernel32`, so an **empty 8-byte stub** `libsynchronization.a` (`ci/winstub/`, wired via `.cargo/config.toml` for the Windows targets only) satisfies the linker. **Workflow:** `release.yaml` = **`test` once (host) → `build` matrix** over the four targets (`needs: test`, `fail-fast: false`); each job packages binary (`.exe` for Windows) + `.sha256` and uploads to the **shared release** via idempotent create-or-get. **macOS deferred** — `arboard`→AppKit needs Apple's SDK, a licensing grey area on a Linux runner, and the **public** CI image can't carry it; its own step (osxcross + a private SDK, or a Mac runner). Alternatives rejected: `cross` (no macOS, needs DinD), per-target nix cross (Windows-aarch64 unpackaged, macOS-from-Linux unsupported), a real `libsynchronization.a` (more machinery, doesn't cover Windows-aarch64). Runtime-verified by the user (2026-06-13): Linux x86_64 + Windows aarch64 run correctly; Linux aarch64 + Windows x86_64 are the outstanding runtime checks. Builds on ADR-ci-002 (flake) and fills in ADR-ci-001 §3 (Release). +- [ADR-ci-003 — Cross-platform release builds (the D1 matrix)](20260613-adr-ci-003.md) — **Accepted 2026-06-13** (implemented + a real matrix release verified the same day — tag `v.0.0.0-citest3` published 8 assets). Cross-compiles the **four non-macOS D1 targets** from the Linux x86_64 runner with **`cargo-zigbuild`** (Zig's bundled clang + libc as one universal cross cc/linker, incl. rusqlite's bundled SQLite C; added to the flake devShell, replacing the single-target musl cc): **`x86_64`/`aarch64-unknown-linux-musl`** (musl + crt-static → fully static, **D2**) and **`x86_64-pc-windows-gnu`** / **`aarch64-pc-windows-gnullvm`** (Zig statically links libc → standalone `.exe`). **Windows `synchronization` stub:** Rust std links `-lsynchronization` (WaitOnAddress thread-parking), an import lib rust-overlay's toolchain doesn't ship and Zig's mingw lacks; the symbols are forwarded by `kernel32`, so an **empty 8-byte stub** `libsynchronization.a` (`ci/winstub/`, wired via `.cargo/config.toml` for the Windows targets only) satisfies the linker. **Workflow:** `release.yaml` = **`test` once (host) → `build` matrix** over the four targets (`needs: test`, `fail-fast: false`); each job packages binary (`.exe` for Windows) + `.sha256` and uploads to the **shared release** via idempotent create-or-get. **macOS** (2026-06-14 amendment) — built natively on a **Tart (Apple-Silicon) runner** (`runs-on: macos`), which makes the SDK fully licensed and dissolves the grey-area/public-image problem; `release-macos.yaml` is **dispatch-only** (intermittent runner; becomes triggerable once CI is on `main`), de-nixes the binary's libiconv load path (`install_name_tool` → `/usr/lib`) + re-signs ad-hoc, and uploads to the tagged release. **D1 complete (all six targets).** Alternatives rejected: `cross` (no macOS, needs DinD), per-target nix cross (Windows-aarch64 unpackaged, macOS-from-Linux unsupported), a real `libsynchronization.a` (more machinery, doesn't cover Windows-aarch64). Runtime-verified by the user (2026-06-13): Linux x86_64 + Windows aarch64 run correctly; Linux aarch64 + Windows x86_64 are the outstanding runtime checks. Builds on ADR-ci-002 (flake) and fills in ADR-ci-001 §3 (Release).