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