Files
rdbms-playground/docs/ci/adr/20260613-adr-ci-003.md
claude@clouddev1 aeb92f56a7
ci / gate (push) Successful in 3m23s
docs(ci): record macOS implementation in ADR-ci-003 (D1 complete)
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.
2026-06-15 15:56:38 +00:00

10 KiB
Raw Permalink Blame History

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.yamlworkflow_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 testbuild:

  • 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 <triple>, 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 .exes 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 (~24 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-<tag>-<target>[.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).